1 line
4.7 MiB
Plaintext
1 line
4.7 MiB
Plaintext
{"repo": "benben05059997/globalperspective-v1", "files": {"global-perspectives-starter/frontend/src/services/llm.js": {"80945bd7c0196bbb93d47fc1556a9653af98377f": {"t": 1785746567.414885, "n": 10423, "h": "ad361efb41b9520a", "c": "// BYOK (Bring-Your-Own-Key) LLM client for the Analysis Studio (/analyze).\n//\n// The user picks a provider + model and pastes THEIR OWN key. The key lives only\n// in the browser (see utils/byok.js) and the chat call goes browser → provider\n// directly — our servers never see, log, or store it.\n//\n// Two code paths:\n// - OpenAI-compatible chat/completions: OpenAI, DeepSeek, Gemini (compat)\n// - Anthropic Messages API: separate shape + the direct-browser-access header\n//\n// ⚠️ Direct browser calls depend on each provider's CORS policy. Where a provider\n// blocks browser origins the fetch will fail with a CORS/network error — surfaced\n// honestly to the user. (The eventual fallback is a no-store pass-through Lambda.)\n\n// `webSearch` capability per provider:\n// 'always' — every call searches the web natively (Perplexity sonar)\n// 'tool' — search happens only when we attach the provider's web-search tool (Anthropic)\n// absent — the API cannot search; Deep-research mode is disabled for it (honest gating,\n// never silently degraded: a \"search the web\" prompt to a no-search API would\n// make the model FAKE having searched).\nexport const PROVIDERS = [\n {\n id: 'deepseek',\n label: 'DeepSeek',\n type: 'openai',\n baseUrl: 'https://api.deepseek.com/v1',\n keyHint: 'sk-…',\n // Explicit V4 IDs (verified against the live /models endpoint 2026-06-12) so the\n // version is visible. The old deepseek-chat/-reasoner aliases now just point at\n // V4-Flash and retire 2026-07-24 — kept as clearly-labelled legacy fallbacks.\n models: ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'],\n modelLabels: {\n 'deepseek-v4-flash': 'DeepSeek V4 Flash — fast',\n 'deepseek-v4-pro': 'DeepSeek V4 Pro — strongest (best for analysis)',\n 'deepseek-chat': 'deepseek-chat (legacy → V4 Flash, retires Jul 24 2026)',\n 'deepseek-reasoner': 'deepseek-reasoner (legacy → V4 Flash thinking, retires Jul 24 2026)',\n },\n // V4 models default to THINKING mode (emit reasoning_content, burn the token\n // budget, and can truncate the answer). Force the plain non-thinking chat the\n // Studio expects — verified accepted by all four DeepSeek model IDs above.\n extraBody: { thinking: { type: 'disabled' } },\n },\n {\n id: 'perplexity',\n label: 'Perplexity (built-in web search)',\n type: 'openai',\n baseUrl: 'https://api.perplexity.ai',\n keyHint: 'pplx-…',\n // Sonar models always search the web and return `citations`/`search_results`.\n models: ['sonar-pro', 'sonar', 'sonar-reasoning-pro', 'sonar-deep-research'],\n webSearch: 'always',\n },\n {\n id: 'openai',\n label: 'OpenAI',\n type: 'openai',\n baseUrl: 'https://api.openai.com/v1',\n keyHint: 'sk-…',\n // GPT-5.6 family (released 2026-07-09): gpt-5.6 = Sol (flagship, alias), terra =\n // balanced, luna = fast/cheap. Flagship first (Studio defaults to models[0]).\n models: ['gpt-5.6', 'gpt-5.6-terra', 'gpt-5.6-luna'],\n },\n {\n id: 'gemini',\n label: 'Google Gemini',\n type: 'openai',\n baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',\n keyHint: 'AIza…',\n // Gemini 3.x (GA mid-2026): 3.6-flash (latest stable), 3.5-flash (named stable),\n // 3.5-flash-lite (low-cost). Verified vs ai.google.dev model docs 2026-07-26.\n models: ['gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-3.5-flash-lite'],\n },\n {\n id: 'qwen',\n label: 'Alibaba Qwen',\n type: 'openai',\n // OpenAI-compatible (\"compatible-mode\") endpoint of Alibaba Cloud Model Studio /\n // DashScope. International endpoint — verified against the Model Studio docs\n // 2026-07-10. (China-region key holders use dashscope.aliyuncs.com instead.)\n baseUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',\n keyHint: 'sk-… (workspace keys: sk-ws-…)',\n // qwen3.7-max is the current flagship (Alibaba Cloud Summit, May 2026) — 1M ctx,\n // strongest for reasoning/analysis. qwen3-max is the prior-gen flagship; -plus is\n // the low-cost multimodal tier. Flagship listed first (Studio defaults to models[0]).\n models: ['qwen3.7-max', 'qwen3-max', 'qwen3.7-plus'],\n // Alibaba's WORKSPACE-scoped international keys (sk-ws-…) 401 against the generic\n // host above — they need a workspace-specific host with the WorkspaceId baked in,\n // e.g. https://<WorkspaceId>.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1\n // (Tokyo) or …ap-southeast-1… (Singapore). Verified live 2026-07-10: generic host\n // 401s an sk-ws- key, workspace host returns 200. Let the user override the host.\n allowBaseUrlOverride: true,\n modelLabels: {\n 'qwen3.7-max': 'Qwen3.7-Max — flagship (strongest for analysis)',\n 'qwen3-max': 'Qwen3-Max — prior-gen flagship',\n 'qwen3.7-plus': 'Qwen3.7-Plus — low-cost',\n },\n // qwen3.x models default to extended-thinking (reasoning_content), which on a\n // NON-streaming call (what the Studio makes) burns the token budget and can\n // truncate the answer — same failure mode we disabled for DeepSeek V4. Force\n // plain non-thinking chat. (First thing to confirm live before trusting output.)\n extraBody: { enable_thinking: false },\n },\n {\n id: 'anthropic',\n label: 'Anthropic (Claude)',\n type: 'anthropic',\n baseUrl: 'https://api.anthropic.com/v1',\n keyHint: 'sk-ant-…',\n // Current Claude model IDs (verified 2026-07-26; re-confirm vs the claude-api ref if a run 404s).\n models: ['claude-sonnet-5', 'claude-opus-4-8', 'claude-haiku-4-5-20251001', 'claude-fable-5'],\n webSearch: 'tool',\n },\n];\n\nexport function getProvider(id) {\n return PROVIDERS.find((p) => p.id === id) || null;\n}\n\n// Normalize the provider's web-source metadata to [{ title, url }].\nfunction openAIWebSources(body) {\n // Perplexity: `search_results` [{title,url,date}] (richer) and/or `citations` [url,…].\n if (Array.isArray(body?.search_results) && body.search_results.length) {\n return body.search_results\n .map((r) => ({ title: r.title || r.url, url: r.url }))\n .filter((r) => r.url);\n }\n if (Array.isArray(body?.citations) && body.citations.length) {\n return body.citations.filter(Boolean).map((u) => ({ title: u, url: u }));\n }\n return [];\n}\n\nasync function runOpenAICompat(provider, { model, apiKey, system, user, maxTokens, temperature, baseUrl }) {\n // Per-provider base-URL override (currently only `qwen`, for workspace-scoped\n // sk-ws- keys that need a WorkspaceId-specific host). Falls back to the provider\n // default for everyone else — unchanged behaviour when unset.\n const base = baseUrl || provider.baseUrl;\n const res = await fetch(`${base}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n max_tokens: maxTokens,\n temperature,\n messages: [\n { role: 'system', content: system },\n { role: 'user', content: user },\n ],\n // Provider-specific extras (e.g. DeepSeek's thinking:disabled). Only present\n // on providers that declare it, so OpenAI/Gemini are unaffected.\n ...(provider.extraBody || {}),\n }),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const text = body?.choices?.[0]?.message?.content;\n if (!text) throw new Error(`${provider.label}: empty response`);\n return { text, webSources: openAIWebSources(body) };\n}\n\nasync function runAnthropic(provider, { model, apiKey, system, user, maxTokens, temperature, webResearch }) {\n const payload = {\n model,\n max_tokens: maxTokens,\n temperature,\n system,\n messages: [{ role: 'user', content: user }],\n };\n // Anthropic's official server-side web-search tool — the model searches and the\n // response carries real source citations. Only attached in Deep-research mode.\n if (webResearch) {\n payload.tools = [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }];\n }\n const res = await fetch(`${provider.baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n // Anthropic's official opt-in to allow calls straight from the browser.\n 'anthropic-dangerous-direct-browser-access': 'true',\n },\n body: JSON.stringify(payload),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const blocks = Array.isArray(body?.content) ? body.content : [];\n const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('\\n');\n if (!text) throw new Error(`${provider.label}: empty response`);\n // Web sources come back as citation objects attached to text blocks.\n const webSources = [];\n const seen = new Set();\n for (const b of blocks) {\n for (const c of b.citations || []) {\n if (c.url && !seen.has(c.url)) {\n seen.add(c.url);\n webSources.push({ title: c.title || c.url, url: c.url });\n }\n }\n }\n return { text, webSources };\n}\n\n// Run one analysis. `system` + `user` are plain strings.\n// Returns { text, webSources:[{title,url}] } — webSources is [] unless the provider\n// searched the web (Perplexity always; Anthropic when `webResearch` is set).\nexport async function runChat({ provider, model, apiKey, system, user, maxTokens = 1600, temperature = 0.3, webResearch = false, baseUrl }) {\n const p = getProvider(provider);\n if (!p) throw new Error(`Unknown provider: ${provider}`);\n if (!apiKey) throw new Error('No API key set');\n if (webResearch && !p.webSearch) {\n // Refuse rather than fake it: prompting a no-search API to \"search the web\"\n // produces invented sources — the exact failure this feature exists to prevent.\n throw new Error(`${p.label} cannot search the web from its API. Pick Perplexity or Anthropic for Deep research.`);\n }\n const args = { model, apiKey, system, user, maxTokens, temperature, webResearch, baseUrl };\n return p.type === 'anthropic' ? runAnthropic(p, args) : runOpenAICompat(p, args);\n}\n"}, "f05fc49c7887fe440fa1156147adcdde95169eaa": {"t": 1785765776.6044455, "n": 10423, "h": "ad361efb41b9520a", "c": "// BYOK (Bring-Your-Own-Key) LLM client for the Analysis Studio (/analyze).\n//\n// The user picks a provider + model and pastes THEIR OWN key. The key lives only\n// in the browser (see utils/byok.js) and the chat call goes browser → provider\n// directly — our servers never see, log, or store it.\n//\n// Two code paths:\n// - OpenAI-compatible chat/completions: OpenAI, DeepSeek, Gemini (compat)\n// - Anthropic Messages API: separate shape + the direct-browser-access header\n//\n// ⚠️ Direct browser calls depend on each provider's CORS policy. Where a provider\n// blocks browser origins the fetch will fail with a CORS/network error — surfaced\n// honestly to the user. (The eventual fallback is a no-store pass-through Lambda.)\n\n// `webSearch` capability per provider:\n// 'always' — every call searches the web natively (Perplexity sonar)\n// 'tool' — search happens only when we attach the provider's web-search tool (Anthropic)\n// absent — the API cannot search; Deep-research mode is disabled for it (honest gating,\n// never silently degraded: a \"search the web\" prompt to a no-search API would\n// make the model FAKE having searched).\nexport const PROVIDERS = [\n {\n id: 'deepseek',\n label: 'DeepSeek',\n type: 'openai',\n baseUrl: 'https://api.deepseek.com/v1',\n keyHint: 'sk-…',\n // Explicit V4 IDs (verified against the live /models endpoint 2026-06-12) so the\n // version is visible. The old deepseek-chat/-reasoner aliases now just point at\n // V4-Flash and retire 2026-07-24 — kept as clearly-labelled legacy fallbacks.\n models: ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'],\n modelLabels: {\n 'deepseek-v4-flash': 'DeepSeek V4 Flash — fast',\n 'deepseek-v4-pro': 'DeepSeek V4 Pro — strongest (best for analysis)',\n 'deepseek-chat': 'deepseek-chat (legacy → V4 Flash, retires Jul 24 2026)',\n 'deepseek-reasoner': 'deepseek-reasoner (legacy → V4 Flash thinking, retires Jul 24 2026)',\n },\n // V4 models default to THINKING mode (emit reasoning_content, burn the token\n // budget, and can truncate the answer). Force the plain non-thinking chat the\n // Studio expects — verified accepted by all four DeepSeek model IDs above.\n extraBody: { thinking: { type: 'disabled' } },\n },\n {\n id: 'perplexity',\n label: 'Perplexity (built-in web search)',\n type: 'openai',\n baseUrl: 'https://api.perplexity.ai',\n keyHint: 'pplx-…',\n // Sonar models always search the web and return `citations`/`search_results`.\n models: ['sonar-pro', 'sonar', 'sonar-reasoning-pro', 'sonar-deep-research'],\n webSearch: 'always',\n },\n {\n id: 'openai',\n label: 'OpenAI',\n type: 'openai',\n baseUrl: 'https://api.openai.com/v1',\n keyHint: 'sk-…',\n // GPT-5.6 family (released 2026-07-09): gpt-5.6 = Sol (flagship, alias), terra =\n // balanced, luna = fast/cheap. Flagship first (Studio defaults to models[0]).\n models: ['gpt-5.6', 'gpt-5.6-terra', 'gpt-5.6-luna'],\n },\n {\n id: 'gemini',\n label: 'Google Gemini',\n type: 'openai',\n baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',\n keyHint: 'AIza…',\n // Gemini 3.x (GA mid-2026): 3.6-flash (latest stable), 3.5-flash (named stable),\n // 3.5-flash-lite (low-cost). Verified vs ai.google.dev model docs 2026-07-26.\n models: ['gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-3.5-flash-lite'],\n },\n {\n id: 'qwen',\n label: 'Alibaba Qwen',\n type: 'openai',\n // OpenAI-compatible (\"compatible-mode\") endpoint of Alibaba Cloud Model Studio /\n // DashScope. International endpoint — verified against the Model Studio docs\n // 2026-07-10. (China-region key holders use dashscope.aliyuncs.com instead.)\n baseUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',\n keyHint: 'sk-… (workspace keys: sk-ws-…)',\n // qwen3.7-max is the current flagship (Alibaba Cloud Summit, May 2026) — 1M ctx,\n // strongest for reasoning/analysis. qwen3-max is the prior-gen flagship; -plus is\n // the low-cost multimodal tier. Flagship listed first (Studio defaults to models[0]).\n models: ['qwen3.7-max', 'qwen3-max', 'qwen3.7-plus'],\n // Alibaba's WORKSPACE-scoped international keys (sk-ws-…) 401 against the generic\n // host above — they need a workspace-specific host with the WorkspaceId baked in,\n // e.g. https://<WorkspaceId>.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1\n // (Tokyo) or …ap-southeast-1… (Singapore). Verified live 2026-07-10: generic host\n // 401s an sk-ws- key, workspace host returns 200. Let the user override the host.\n allowBaseUrlOverride: true,\n modelLabels: {\n 'qwen3.7-max': 'Qwen3.7-Max — flagship (strongest for analysis)',\n 'qwen3-max': 'Qwen3-Max — prior-gen flagship',\n 'qwen3.7-plus': 'Qwen3.7-Plus — low-cost',\n },\n // qwen3.x models default to extended-thinking (reasoning_content), which on a\n // NON-streaming call (what the Studio makes) burns the token budget and can\n // truncate the answer — same failure mode we disabled for DeepSeek V4. Force\n // plain non-thinking chat. (First thing to confirm live before trusting output.)\n extraBody: { enable_thinking: false },\n },\n {\n id: 'anthropic',\n label: 'Anthropic (Claude)',\n type: 'anthropic',\n baseUrl: 'https://api.anthropic.com/v1',\n keyHint: 'sk-ant-…',\n // Current Claude model IDs (verified 2026-07-26; re-confirm vs the claude-api ref if a run 404s).\n models: ['claude-sonnet-5', 'claude-opus-4-8', 'claude-haiku-4-5-20251001', 'claude-fable-5'],\n webSearch: 'tool',\n },\n];\n\nexport function getProvider(id) {\n return PROVIDERS.find((p) => p.id === id) || null;\n}\n\n// Normalize the provider's web-source metadata to [{ title, url }].\nfunction openAIWebSources(body) {\n // Perplexity: `search_results` [{title,url,date}] (richer) and/or `citations` [url,…].\n if (Array.isArray(body?.search_results) && body.search_results.length) {\n return body.search_results\n .map((r) => ({ title: r.title || r.url, url: r.url }))\n .filter((r) => r.url);\n }\n if (Array.isArray(body?.citations) && body.citations.length) {\n return body.citations.filter(Boolean).map((u) => ({ title: u, url: u }));\n }\n return [];\n}\n\nasync function runOpenAICompat(provider, { model, apiKey, system, user, maxTokens, temperature, baseUrl }) {\n // Per-provider base-URL override (currently only `qwen`, for workspace-scoped\n // sk-ws- keys that need a WorkspaceId-specific host). Falls back to the provider\n // default for everyone else — unchanged behaviour when unset.\n const base = baseUrl || provider.baseUrl;\n const res = await fetch(`${base}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n max_tokens: maxTokens,\n temperature,\n messages: [\n { role: 'system', content: system },\n { role: 'user', content: user },\n ],\n // Provider-specific extras (e.g. DeepSeek's thinking:disabled). Only present\n // on providers that declare it, so OpenAI/Gemini are unaffected.\n ...(provider.extraBody || {}),\n }),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const text = body?.choices?.[0]?.message?.content;\n if (!text) throw new Error(`${provider.label}: empty response`);\n return { text, webSources: openAIWebSources(body) };\n}\n\nasync function runAnthropic(provider, { model, apiKey, system, user, maxTokens, temperature, webResearch }) {\n const payload = {\n model,\n max_tokens: maxTokens,\n temperature,\n system,\n messages: [{ role: 'user', content: user }],\n };\n // Anthropic's official server-side web-search tool — the model searches and the\n // response carries real source citations. Only attached in Deep-research mode.\n if (webResearch) {\n payload.tools = [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }];\n }\n const res = await fetch(`${provider.baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n // Anthropic's official opt-in to allow calls straight from the browser.\n 'anthropic-dangerous-direct-browser-access': 'true',\n },\n body: JSON.stringify(payload),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const blocks = Array.isArray(body?.content) ? body.content : [];\n const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('\\n');\n if (!text) throw new Error(`${provider.label}: empty response`);\n // Web sources come back as citation objects attached to text blocks.\n const webSources = [];\n const seen = new Set();\n for (const b of blocks) {\n for (const c of b.citations || []) {\n if (c.url && !seen.has(c.url)) {\n seen.add(c.url);\n webSources.push({ title: c.title || c.url, url: c.url });\n }\n }\n }\n return { text, webSources };\n}\n\n// Run one analysis. `system` + `user` are plain strings.\n// Returns { text, webSources:[{title,url}] } — webSources is [] unless the provider\n// searched the web (Perplexity always; Anthropic when `webResearch` is set).\nexport async function runChat({ provider, model, apiKey, system, user, maxTokens = 1600, temperature = 0.3, webResearch = false, baseUrl }) {\n const p = getProvider(provider);\n if (!p) throw new Error(`Unknown provider: ${provider}`);\n if (!apiKey) throw new Error('No API key set');\n if (webResearch && !p.webSearch) {\n // Refuse rather than fake it: prompting a no-search API to \"search the web\"\n // produces invented sources — the exact failure this feature exists to prevent.\n throw new Error(`${p.label} cannot search the web from its API. Pick Perplexity or Anthropic for Deep research.`);\n }\n const args = { model, apiKey, system, user, maxTokens, temperature, webResearch, baseUrl };\n return p.type === 'anthropic' ? runAnthropic(p, args) : runOpenAICompat(p, args);\n}\n"}, "95429ae6521b678e8cf9e74bb18dea9b702e263d": {"t": 1785765778.0300303, "n": 10057, "h": "97fc3966c2079c8f", "c": "// BYOK (Bring-Your-Own-Key) LLM client for the Analysis Studio (/analyze).\n//\n// The user picks a provider + model and pastes THEIR OWN key. The key lives only\n// in the browser (see utils/byok.js) and the chat call goes browser → provider\n// directly — our servers never see, log, or store it.\n//\n// Two code paths:\n// - OpenAI-compatible chat/completions: OpenAI, DeepSeek, Gemini (compat)\n// - Anthropic Messages API: separate shape + the direct-browser-access header\n//\n// ⚠️ Direct browser calls depend on each provider's CORS policy. Where a provider\n// blocks browser origins the fetch will fail with a CORS/network error — surfaced\n// honestly to the user. (The eventual fallback is a no-store pass-through Lambda.)\n\n// `webSearch` capability per provider:\n// 'always' — every call searches the web natively (Perplexity sonar)\n// 'tool' — search happens only when we attach the provider's web-search tool (Anthropic)\n// absent — the API cannot search; Deep-research mode is disabled for it (honest gating,\n// never silently degraded: a \"search the web\" prompt to a no-search API would\n// make the model FAKE having searched).\nexport const PROVIDERS = [\n {\n id: 'deepseek',\n label: 'DeepSeek',\n type: 'openai',\n baseUrl: 'https://api.deepseek.com/v1',\n keyHint: 'sk-…',\n // Explicit V4 IDs (verified against the live /models endpoint 2026-06-12) so the\n // version is visible. The old deepseek-chat/-reasoner aliases now just point at\n // V4-Flash and retire 2026-07-24 — kept as clearly-labelled legacy fallbacks.\n models: ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'],\n modelLabels: {\n 'deepseek-v4-flash': 'DeepSeek V4 Flash — fast',\n 'deepseek-v4-pro': 'DeepSeek V4 Pro — strongest (best for analysis)',\n 'deepseek-chat': 'deepseek-chat (legacy → V4 Flash, retires Jul 24 2026)',\n 'deepseek-reasoner': 'deepseek-reasoner (legacy → V4 Flash thinking, retires Jul 24 2026)',\n },\n // V4 models default to THINKING mode (emit reasoning_content, burn the token\n // budget, and can truncate the answer). Force the plain non-thinking chat the\n // Studio expects — verified accepted by all four DeepSeek model IDs above.\n extraBody: { thinking: { type: 'disabled' } },\n },\n {\n id: 'perplexity',\n label: 'Perplexity (built-in web search)',\n type: 'openai',\n baseUrl: 'https://api.perplexity.ai',\n keyHint: 'pplx-…',\n // Sonar models always search the web and return `citations`/`search_results`.\n models: ['sonar-pro', 'sonar', 'sonar-reasoning-pro', 'sonar-deep-research'],\n webSearch: 'always',\n },\n {\n id: 'openai',\n label: 'OpenAI',\n type: 'openai',\n baseUrl: 'https://api.openai.com/v1',\n keyHint: 'sk-…',\n models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini'],\n },\n {\n id: 'gemini',\n label: 'Google Gemini',\n type: 'openai',\n baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',\n keyHint: 'AIza…',\n models: ['gemini-2.5-flash', 'gemini-2.5-pro'],\n },\n {\n id: 'qwen',\n label: 'Alibaba Qwen',\n type: 'openai',\n // OpenAI-compatible (\"compatible-mode\") endpoint of Alibaba Cloud Model Studio /\n // DashScope. International endpoint — verified against the Model Studio docs\n // 2026-07-10. (China-region key holders use dashscope.aliyuncs.com instead.)\n baseUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',\n keyHint: 'sk-… (workspace keys: sk-ws-…)',\n // qwen3.7-max is the current flagship (Alibaba Cloud Summit, May 2026) — 1M ctx,\n // strongest for reasoning/analysis. qwen3-max is the prior-gen flagship; -plus is\n // the low-cost multimodal tier. Flagship listed first (Studio defaults to models[0]).\n models: ['qwen3.7-max', 'qwen3-max', 'qwen3.7-plus'],\n // Alibaba's WORKSPACE-scoped international keys (sk-ws-…) 401 against the generic\n // host above — they need a workspace-specific host with the WorkspaceId baked in,\n // e.g. https://<WorkspaceId>.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1\n // (Tokyo) or …ap-southeast-1… (Singapore). Verified live 2026-07-10: generic host\n // 401s an sk-ws- key, workspace host returns 200. Let the user override the host.\n allowBaseUrlOverride: true,\n modelLabels: {\n 'qwen3.7-max': 'Qwen3.7-Max — flagship (strongest for analysis)',\n 'qwen3-max': 'Qwen3-Max — prior-gen flagship',\n 'qwen3.7-plus': 'Qwen3.7-Plus — low-cost',\n },\n // qwen3.x models default to extended-thinking (reasoning_content), which on a\n // NON-streaming call (what the Studio makes) burns the token budget and can\n // truncate the answer — same failure mode we disabled for DeepSeek V4. Force\n // plain non-thinking chat. (First thing to confirm live before trusting output.)\n extraBody: { enable_thinking: false },\n },\n {\n id: 'anthropic',\n label: 'Anthropic (Claude)',\n type: 'anthropic',\n baseUrl: 'https://api.anthropic.com/v1',\n keyHint: 'sk-ant-…',\n // Current Claude model IDs (confirm against the claude-api reference before shipping).\n models: ['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-haiku-4-5-20251001', 'claude-fable-5'],\n webSearch: 'tool',\n },\n];\n\nexport function getProvider(id) {\n return PROVIDERS.find((p) => p.id === id) || null;\n}\n\n// Normalize the provider's web-source metadata to [{ title, url }].\nfunction openAIWebSources(body) {\n // Perplexity: `search_results` [{title,url,date}] (richer) and/or `citations` [url,…].\n if (Array.isArray(body?.search_results) && body.search_results.length) {\n return body.search_results\n .map((r) => ({ title: r.title || r.url, url: r.url }))\n .filter((r) => r.url);\n }\n if (Array.isArray(body?.citations) && body.citations.length) {\n return body.citations.filter(Boolean).map((u) => ({ title: u, url: u }));\n }\n return [];\n}\n\nasync function runOpenAICompat(provider, { model, apiKey, system, user, maxTokens, temperature, baseUrl }) {\n // Per-provider base-URL override (currently only `qwen`, for workspace-scoped\n // sk-ws- keys that need a WorkspaceId-specific host). Falls back to the provider\n // default for everyone else — unchanged behaviour when unset.\n const base = baseUrl || provider.baseUrl;\n const res = await fetch(`${base}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n max_tokens: maxTokens,\n temperature,\n messages: [\n { role: 'system', content: system },\n { role: 'user', content: user },\n ],\n // Provider-specific extras (e.g. DeepSeek's thinking:disabled). Only present\n // on providers that declare it, so OpenAI/Gemini are unaffected.\n ...(provider.extraBody || {}),\n }),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const text = body?.choices?.[0]?.message?.content;\n if (!text) throw new Error(`${provider.label}: empty response`);\n return { text, webSources: openAIWebSources(body) };\n}\n\nasync function runAnthropic(provider, { model, apiKey, system, user, maxTokens, temperature, webResearch }) {\n const payload = {\n model,\n max_tokens: maxTokens,\n temperature,\n system,\n messages: [{ role: 'user', content: user }],\n };\n // Anthropic's official server-side web-search tool — the model searches and the\n // response carries real source citations. Only attached in Deep-research mode.\n if (webResearch) {\n payload.tools = [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }];\n }\n const res = await fetch(`${provider.baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n // Anthropic's official opt-in to allow calls straight from the browser.\n 'anthropic-dangerous-direct-browser-access': 'true',\n },\n body: JSON.stringify(payload),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const blocks = Array.isArray(body?.content) ? body.content : [];\n const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('\\n');\n if (!text) throw new Error(`${provider.label}: empty response`);\n // Web sources come back as citation objects attached to text blocks.\n const webSources = [];\n const seen = new Set();\n for (const b of blocks) {\n for (const c of b.citations || []) {\n if (c.url && !seen.has(c.url)) {\n seen.add(c.url);\n webSources.push({ title: c.title || c.url, url: c.url });\n }\n }\n }\n return { text, webSources };\n}\n\n// Run one analysis. `system` + `user` are plain strings.\n// Returns { text, webSources:[{title,url}] } — webSources is [] unless the provider\n// searched the web (Perplexity always; Anthropic when `webResearch` is set).\nexport async function runChat({ provider, model, apiKey, system, user, maxTokens = 1600, temperature = 0.3, webResearch = false, baseUrl }) {\n const p = getProvider(provider);\n if (!p) throw new Error(`Unknown provider: ${provider}`);\n if (!apiKey) throw new Error('No API key set');\n if (webResearch && !p.webSearch) {\n // Refuse rather than fake it: prompting a no-search API to \"search the web\"\n // produces invented sources — the exact failure this feature exists to prevent.\n throw new Error(`${p.label} cannot search the web from its API. Pick Perplexity or Anthropic for Deep research.`);\n }\n const args = { model, apiKey, system, user, maxTokens, temperature, webResearch, baseUrl };\n return p.type === 'anthropic' ? runAnthropic(p, args) : runOpenAICompat(p, args);\n}\n"}, "42d7e3da7369212b7aac6d51e835c6819e3b8845": {"t": 1785765779.7480655, "n": 10371, "h": "c269fd5170a78946", "c": "// BYOK (Bring-Your-Own-Key) LLM client for the Analysis Studio (/analyze).\n//\n// The user picks a provider + model and pastes THEIR OWN key. The key lives only\n// in the browser (see utils/byok.js) and the chat call goes browser → provider\n// directly — our servers never see, log, or store it.\n//\n// Two code paths:\n// - OpenAI-compatible chat/completions: OpenAI, DeepSeek, Gemini (compat), OpenRouter\n// - Anthropic Messages API: separate shape + the direct-browser-access header\n//\n// ⚠️ Direct browser calls depend on each provider's CORS policy. Where a provider\n// blocks browser origins the fetch will fail with a CORS/network error — surfaced\n// honestly to the user. (The eventual fallback is a no-store pass-through Lambda.)\n\n// `webSearch` capability per provider:\n// 'always' — every call searches the web natively (Perplexity sonar)\n// 'tool' — search happens only when we attach the provider's web-search tool (Anthropic)\n// absent — the API cannot search; Deep-research mode is disabled for it (honest gating,\n// never silently degraded: a \"search the web\" prompt to a no-search API would\n// make the model FAKE having searched).\nexport const PROVIDERS = [\n {\n id: 'deepseek',\n label: 'DeepSeek',\n type: 'openai',\n baseUrl: 'https://api.deepseek.com/v1',\n keyHint: 'sk-…',\n // Explicit V4 IDs (verified against the live /models endpoint 2026-06-12) so the\n // version is visible. The old deepseek-chat/-reasoner aliases now just point at\n // V4-Flash and retire 2026-07-24 — kept as clearly-labelled legacy fallbacks.\n models: ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'],\n modelLabels: {\n 'deepseek-v4-flash': 'DeepSeek V4 Flash — fast',\n 'deepseek-v4-pro': 'DeepSeek V4 Pro — strongest (best for analysis)',\n 'deepseek-chat': 'deepseek-chat (legacy → V4 Flash, retires Jul 24 2026)',\n 'deepseek-reasoner': 'deepseek-reasoner (legacy → V4 Flash thinking, retires Jul 24 2026)',\n },\n // V4 models default to THINKING mode (emit reasoning_content, burn the token\n // budget, and can truncate the answer). Force the plain non-thinking chat the\n // Studio expects — verified accepted by all four DeepSeek model IDs above.\n extraBody: { thinking: { type: 'disabled' } },\n },\n {\n id: 'perplexity',\n label: 'Perplexity (built-in web search)',\n type: 'openai',\n baseUrl: 'https://api.perplexity.ai',\n keyHint: 'pplx-…',\n // Sonar models always search the web and return `citations`/`search_results`.\n models: ['sonar-pro', 'sonar', 'sonar-reasoning-pro', 'sonar-deep-research'],\n webSearch: 'always',\n },\n {\n id: 'openai',\n label: 'OpenAI',\n type: 'openai',\n baseUrl: 'https://api.openai.com/v1',\n keyHint: 'sk-…',\n models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini'],\n },\n {\n id: 'gemini',\n label: 'Google Gemini',\n type: 'openai',\n baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',\n keyHint: 'AIza…',\n models: ['gemini-2.5-flash', 'gemini-2.5-pro'],\n },\n {\n id: 'qwen',\n label: 'Alibaba Qwen',\n type: 'openai',\n // OpenAI-compatible (\"compatible-mode\") endpoint of Alibaba Cloud Model Studio /\n // DashScope. International endpoint — verified against the Model Studio docs\n // 2026-07-10. (China-region key holders use dashscope.aliyuncs.com instead.)\n baseUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',\n keyHint: 'sk-… (workspace keys: sk-ws-…)',\n // qwen3.7-max is the current flagship (Alibaba Cloud Summit, May 2026) — 1M ctx,\n // strongest for reasoning/analysis. qwen3-max is the prior-gen flagship; -plus is\n // the low-cost multimodal tier. Flagship listed first (Studio defaults to models[0]).\n models: ['qwen3.7-max', 'qwen3-max', 'qwen3.7-plus'],\n // Alibaba's WORKSPACE-scoped international keys (sk-ws-…) 401 against the generic\n // host above — they need a workspace-specific host with the WorkspaceId baked in,\n // e.g. https://<WorkspaceId>.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1\n // (Tokyo) or …ap-southeast-1… (Singapore). Verified live 2026-07-10: generic host\n // 401s an sk-ws- key, workspace host returns 200. Let the user override the host.\n allowBaseUrlOverride: true,\n modelLabels: {\n 'qwen3.7-max': 'Qwen3.7-Max — flagship (strongest for analysis)',\n 'qwen3-max': 'Qwen3-Max — prior-gen flagship',\n 'qwen3.7-plus': 'Qwen3.7-Plus — low-cost',\n },\n // qwen3.x models default to extended-thinking (reasoning_content), which on a\n // NON-streaming call (what the Studio makes) burns the token budget and can\n // truncate the answer — same failure mode we disabled for DeepSeek V4. Force\n // plain non-thinking chat. (First thing to confirm live before trusting output.)\n extraBody: { enable_thinking: false },\n },\n {\n id: 'openrouter',\n label: 'OpenRouter',\n type: 'openai',\n baseUrl: 'https://openrouter.ai/api/v1',\n keyHint: 'sk-or-…',\n models: [\n 'deepseek/deepseek-chat',\n 'openai/gpt-4o',\n 'anthropic/claude-sonnet-4.6',\n 'google/gemini-2.5-flash',\n ],\n },\n {\n id: 'anthropic',\n label: 'Anthropic (Claude)',\n type: 'anthropic',\n baseUrl: 'https://api.anthropic.com/v1',\n keyHint: 'sk-ant-…',\n // Current Claude model IDs (confirm against the claude-api reference before shipping).\n models: ['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-haiku-4-5-20251001', 'claude-fable-5'],\n webSearch: 'tool',\n },\n];\n\nexport function getProvider(id) {\n return PROVIDERS.find((p) => p.id === id) || null;\n}\n\n// Normalize the provider's web-source metadata to [{ title, url }].\nfunction openAIWebSources(body) {\n // Perplexity: `search_results` [{title,url,date}] (richer) and/or `citations` [url,…].\n if (Array.isArray(body?.search_results) && body.search_results.length) {\n return body.search_results\n .map((r) => ({ title: r.title || r.url, url: r.url }))\n .filter((r) => r.url);\n }\n if (Array.isArray(body?.citations) && body.citations.length) {\n return body.citations.filter(Boolean).map((u) => ({ title: u, url: u }));\n }\n return [];\n}\n\nasync function runOpenAICompat(provider, { model, apiKey, system, user, maxTokens, temperature, baseUrl }) {\n // Per-provider base-URL override (currently only `qwen`, for workspace-scoped\n // sk-ws- keys that need a WorkspaceId-specific host). Falls back to the provider\n // default for everyone else — unchanged behaviour when unset.\n const base = baseUrl || provider.baseUrl;\n const res = await fetch(`${base}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n max_tokens: maxTokens,\n temperature,\n messages: [\n { role: 'system', content: system },\n { role: 'user', content: user },\n ],\n // Provider-specific extras (e.g. DeepSeek's thinking:disabled). Only present\n // on providers that declare it, so OpenAI/Gemini/OpenRouter are unaffected.\n ...(provider.extraBody || {}),\n }),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const text = body?.choices?.[0]?.message?.content;\n if (!text) throw new Error(`${provider.label}: empty response`);\n return { text, webSources: openAIWebSources(body) };\n}\n\nasync function runAnthropic(provider, { model, apiKey, system, user, maxTokens, temperature, webResearch }) {\n const payload = {\n model,\n max_tokens: maxTokens,\n temperature,\n system,\n messages: [{ role: 'user', content: user }],\n };\n // Anthropic's official server-side web-search tool — the model searches and the\n // response carries real source citations. Only attached in Deep-research mode.\n if (webResearch) {\n payload.tools = [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }];\n }\n const res = await fetch(`${provider.baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n // Anthropic's official opt-in to allow calls straight from the browser.\n 'anthropic-dangerous-direct-browser-access': 'true',\n },\n body: JSON.stringify(payload),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const blocks = Array.isArray(body?.content) ? body.content : [];\n const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('\\n');\n if (!text) throw new Error(`${provider.label}: empty response`);\n // Web sources come back as citation objects attached to text blocks.\n const webSources = [];\n const seen = new Set();\n for (const b of blocks) {\n for (const c of b.citations || []) {\n if (c.url && !seen.has(c.url)) {\n seen.add(c.url);\n webSources.push({ title: c.title || c.url, url: c.url });\n }\n }\n }\n return { text, webSources };\n}\n\n// Run one analysis. `system` + `user` are plain strings.\n// Returns { text, webSources:[{title,url}] } — webSources is [] unless the provider\n// searched the web (Perplexity always; Anthropic when `webResearch` is set).\nexport async function runChat({ provider, model, apiKey, system, user, maxTokens = 1600, temperature = 0.3, webResearch = false, baseUrl }) {\n const p = getProvider(provider);\n if (!p) throw new Error(`Unknown provider: ${provider}`);\n if (!apiKey) throw new Error('No API key set');\n if (webResearch && !p.webSearch) {\n // Refuse rather than fake it: prompting a no-search API to \"search the web\"\n // produces invented sources — the exact failure this feature exists to prevent.\n throw new Error(`${p.label} cannot search the web from its API. Pick Perplexity or Anthropic for Deep research.`);\n }\n const args = { model, apiKey, system, user, maxTokens, temperature, webResearch, baseUrl };\n return p.type === 'anthropic' ? runAnthropic(p, args) : runOpenAICompat(p, args);\n}\n"}, "ce226b1f067f61e7dedaf2114ce577af5cd1c529": {"t": 1785765781.2090583, "n": 8279, "h": "6a8d1403c6ff50f1", "c": "// BYOK (Bring-Your-Own-Key) LLM client for the Analysis Studio (/analyze).\n//\n// The user picks a provider + model and pastes THEIR OWN key. The key lives only\n// in the browser (see utils/byok.js) and the chat call goes browser → provider\n// directly — our servers never see, log, or store it.\n//\n// Two code paths:\n// - OpenAI-compatible chat/completions: OpenAI, DeepSeek, Gemini (compat), OpenRouter\n// - Anthropic Messages API: separate shape + the direct-browser-access header\n//\n// ⚠️ Direct browser calls depend on each provider's CORS policy. Where a provider\n// blocks browser origins the fetch will fail with a CORS/network error — surfaced\n// honestly to the user. (The eventual fallback is a no-store pass-through Lambda.)\n\n// `webSearch` capability per provider:\n// 'always' — every call searches the web natively (Perplexity sonar)\n// 'tool' — search happens only when we attach the provider's web-search tool (Anthropic)\n// absent — the API cannot search; Deep-research mode is disabled for it (honest gating,\n// never silently degraded: a \"search the web\" prompt to a no-search API would\n// make the model FAKE having searched).\nexport const PROVIDERS = [\n {\n id: 'deepseek',\n label: 'DeepSeek',\n type: 'openai',\n baseUrl: 'https://api.deepseek.com/v1',\n keyHint: 'sk-…',\n // Explicit V4 IDs (verified against the live /models endpoint 2026-06-12) so the\n // version is visible. The old deepseek-chat/-reasoner aliases now just point at\n // V4-Flash and retire 2026-07-24 — kept as clearly-labelled legacy fallbacks.\n models: ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'],\n modelLabels: {\n 'deepseek-v4-flash': 'DeepSeek V4 Flash — fast',\n 'deepseek-v4-pro': 'DeepSeek V4 Pro — strongest (best for analysis)',\n 'deepseek-chat': 'deepseek-chat (legacy → V4 Flash, retires Jul 24 2026)',\n 'deepseek-reasoner': 'deepseek-reasoner (legacy → V4 Flash thinking, retires Jul 24 2026)',\n },\n // V4 models default to THINKING mode (emit reasoning_content, burn the token\n // budget, and can truncate the answer). Force the plain non-thinking chat the\n // Studio expects — verified accepted by all four DeepSeek model IDs above.\n extraBody: { thinking: { type: 'disabled' } },\n },\n {\n id: 'perplexity',\n label: 'Perplexity (built-in web search)',\n type: 'openai',\n baseUrl: 'https://api.perplexity.ai',\n keyHint: 'pplx-…',\n // Sonar models always search the web and return `citations`/`search_results`.\n models: ['sonar-pro', 'sonar', 'sonar-reasoning-pro', 'sonar-deep-research'],\n webSearch: 'always',\n },\n {\n id: 'openai',\n label: 'OpenAI',\n type: 'openai',\n baseUrl: 'https://api.openai.com/v1',\n keyHint: 'sk-…',\n models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini'],\n },\n {\n id: 'gemini',\n label: 'Google Gemini',\n type: 'openai',\n baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',\n keyHint: 'AIza…',\n models: ['gemini-2.5-flash', 'gemini-2.5-pro'],\n },\n {\n id: 'openrouter',\n label: 'OpenRouter',\n type: 'openai',\n baseUrl: 'https://openrouter.ai/api/v1',\n keyHint: 'sk-or-…',\n models: [\n 'deepseek/deepseek-chat',\n 'openai/gpt-4o',\n 'anthropic/claude-sonnet-4.6',\n 'google/gemini-2.5-flash',\n ],\n },\n {\n id: 'anthropic',\n label: 'Anthropic (Claude)',\n type: 'anthropic',\n baseUrl: 'https://api.anthropic.com/v1',\n keyHint: 'sk-ant-…',\n // Current Claude model IDs (confirm against the claude-api reference before shipping).\n models: ['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-haiku-4-5-20251001', 'claude-fable-5'],\n webSearch: 'tool',\n },\n];\n\nexport function getProvider(id) {\n return PROVIDERS.find((p) => p.id === id) || null;\n}\n\n// Normalize the provider's web-source metadata to [{ title, url }].\nfunction openAIWebSources(body) {\n // Perplexity: `search_results` [{title,url,date}] (richer) and/or `citations` [url,…].\n if (Array.isArray(body?.search_results) && body.search_results.length) {\n return body.search_results\n .map((r) => ({ title: r.title || r.url, url: r.url }))\n .filter((r) => r.url);\n }\n if (Array.isArray(body?.citations) && body.citations.length) {\n return body.citations.filter(Boolean).map((u) => ({ title: u, url: u }));\n }\n return [];\n}\n\nasync function runOpenAICompat(provider, { model, apiKey, system, user, maxTokens, temperature }) {\n const res = await fetch(`${provider.baseUrl}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n max_tokens: maxTokens,\n temperature,\n messages: [\n { role: 'system', content: system },\n { role: 'user', content: user },\n ],\n // Provider-specific extras (e.g. DeepSeek's thinking:disabled). Only present\n // on providers that declare it, so OpenAI/Gemini/OpenRouter are unaffected.\n ...(provider.extraBody || {}),\n }),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const text = body?.choices?.[0]?.message?.content;\n if (!text) throw new Error(`${provider.label}: empty response`);\n return { text, webSources: openAIWebSources(body) };\n}\n\nasync function runAnthropic(provider, { model, apiKey, system, user, maxTokens, temperature, webResearch }) {\n const payload = {\n model,\n max_tokens: maxTokens,\n temperature,\n system,\n messages: [{ role: 'user', content: user }],\n };\n // Anthropic's official server-side web-search tool — the model searches and the\n // response carries real source citations. Only attached in Deep-research mode.\n if (webResearch) {\n payload.tools = [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }];\n }\n const res = await fetch(`${provider.baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n // Anthropic's official opt-in to allow calls straight from the browser.\n 'anthropic-dangerous-direct-browser-access': 'true',\n },\n body: JSON.stringify(payload),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const blocks = Array.isArray(body?.content) ? body.content : [];\n const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('\\n');\n if (!text) throw new Error(`${provider.label}: empty response`);\n // Web sources come back as citation objects attached to text blocks.\n const webSources = [];\n const seen = new Set();\n for (const b of blocks) {\n for (const c of b.citations || []) {\n if (c.url && !seen.has(c.url)) {\n seen.add(c.url);\n webSources.push({ title: c.title || c.url, url: c.url });\n }\n }\n }\n return { text, webSources };\n}\n\n// Run one analysis. `system` + `user` are plain strings.\n// Returns { text, webSources:[{title,url}] } — webSources is [] unless the provider\n// searched the web (Perplexity always; Anthropic when `webResearch` is set).\nexport async function runChat({ provider, model, apiKey, system, user, maxTokens = 1600, temperature = 0.3, webResearch = false }) {\n const p = getProvider(provider);\n if (!p) throw new Error(`Unknown provider: ${provider}`);\n if (!apiKey) throw new Error('No API key set');\n if (webResearch && !p.webSearch) {\n // Refuse rather than fake it: prompting a no-search API to \"search the web\"\n // produces invented sources — the exact failure this feature exists to prevent.\n throw new Error(`${p.label} cannot search the web from its API. Pick Perplexity or Anthropic for Deep research.`);\n }\n const args = { model, apiKey, system, user, maxTokens, temperature, webResearch };\n return p.type === 'anthropic' ? runAnthropic(p, args) : runOpenAICompat(p, args);\n}\n"}, "f0e214a8b835a41c08fe92fa59766e65fe6cf820": {"t": 1785765782.5235949, "n": 7779, "h": "4819c47f2c904423", "c": "// BYOK (Bring-Your-Own-Key) LLM client for the Analysis Studio (/analyze).\n//\n// The user picks a provider + model and pastes THEIR OWN key. The key lives only\n// in the browser (see utils/byok.js) and the chat call goes browser → provider\n// directly — our servers never see, log, or store it.\n//\n// Two code paths:\n// - OpenAI-compatible chat/completions: OpenAI, DeepSeek, Gemini (compat), OpenRouter\n// - Anthropic Messages API: separate shape + the direct-browser-access header\n//\n// ⚠️ Direct browser calls depend on each provider's CORS policy. Where a provider\n// blocks browser origins the fetch will fail with a CORS/network error — surfaced\n// honestly to the user. (The eventual fallback is a no-store pass-through Lambda.)\n\n// `webSearch` capability per provider:\n// 'always' — every call searches the web natively (Perplexity sonar)\n// 'tool' — search happens only when we attach the provider's web-search tool (Anthropic)\n// absent — the API cannot search; Deep-research mode is disabled for it (honest gating,\n// never silently degraded: a \"search the web\" prompt to a no-search API would\n// make the model FAKE having searched).\nexport const PROVIDERS = [\n {\n id: 'deepseek',\n label: 'DeepSeek',\n type: 'openai',\n baseUrl: 'https://api.deepseek.com/v1',\n keyHint: 'sk-…',\n // Explicit V4 IDs (verified against the live /models endpoint 2026-06-12) so the\n // version is visible. The old deepseek-chat/-reasoner aliases now just point at\n // V4-Flash and retire 2026-07-24 — kept as clearly-labelled legacy fallbacks.\n models: ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'],\n modelLabels: {\n 'deepseek-v4-flash': 'DeepSeek V4 Flash — fast',\n 'deepseek-v4-pro': 'DeepSeek V4 Pro — strongest (best for analysis)',\n 'deepseek-chat': 'deepseek-chat (legacy → V4 Flash, retires Jul 24 2026)',\n 'deepseek-reasoner': 'deepseek-reasoner (legacy → V4 Flash thinking, retires Jul 24 2026)',\n },\n },\n {\n id: 'perplexity',\n label: 'Perplexity (built-in web search)',\n type: 'openai',\n baseUrl: 'https://api.perplexity.ai',\n keyHint: 'pplx-…',\n // Sonar models always search the web and return `citations`/`search_results`.\n models: ['sonar-pro', 'sonar', 'sonar-reasoning-pro', 'sonar-deep-research'],\n webSearch: 'always',\n },\n {\n id: 'openai',\n label: 'OpenAI',\n type: 'openai',\n baseUrl: 'https://api.openai.com/v1',\n keyHint: 'sk-…',\n models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini'],\n },\n {\n id: 'gemini',\n label: 'Google Gemini',\n type: 'openai',\n baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',\n keyHint: 'AIza…',\n models: ['gemini-2.5-flash', 'gemini-2.5-pro'],\n },\n {\n id: 'openrouter',\n label: 'OpenRouter',\n type: 'openai',\n baseUrl: 'https://openrouter.ai/api/v1',\n keyHint: 'sk-or-…',\n models: [\n 'deepseek/deepseek-chat',\n 'openai/gpt-4o',\n 'anthropic/claude-sonnet-4.6',\n 'google/gemini-2.5-flash',\n ],\n },\n {\n id: 'anthropic',\n label: 'Anthropic (Claude)',\n type: 'anthropic',\n baseUrl: 'https://api.anthropic.com/v1',\n keyHint: 'sk-ant-…',\n // Current Claude model IDs (confirm against the claude-api reference before shipping).\n models: ['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-haiku-4-5-20251001', 'claude-fable-5'],\n webSearch: 'tool',\n },\n];\n\nexport function getProvider(id) {\n return PROVIDERS.find((p) => p.id === id) || null;\n}\n\n// Normalize the provider's web-source metadata to [{ title, url }].\nfunction openAIWebSources(body) {\n // Perplexity: `search_results` [{title,url,date}] (richer) and/or `citations` [url,…].\n if (Array.isArray(body?.search_results) && body.search_results.length) {\n return body.search_results\n .map((r) => ({ title: r.title || r.url, url: r.url }))\n .filter((r) => r.url);\n }\n if (Array.isArray(body?.citations) && body.citations.length) {\n return body.citations.filter(Boolean).map((u) => ({ title: u, url: u }));\n }\n return [];\n}\n\nasync function runOpenAICompat(provider, { model, apiKey, system, user, maxTokens, temperature }) {\n const res = await fetch(`${provider.baseUrl}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n max_tokens: maxTokens,\n temperature,\n messages: [\n { role: 'system', content: system },\n { role: 'user', content: user },\n ],\n }),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const text = body?.choices?.[0]?.message?.content;\n if (!text) throw new Error(`${provider.label}: empty response`);\n return { text, webSources: openAIWebSources(body) };\n}\n\nasync function runAnthropic(provider, { model, apiKey, system, user, maxTokens, temperature, webResearch }) {\n const payload = {\n model,\n max_tokens: maxTokens,\n temperature,\n system,\n messages: [{ role: 'user', content: user }],\n };\n // Anthropic's official server-side web-search tool — the model searches and the\n // response carries real source citations. Only attached in Deep-research mode.\n if (webResearch) {\n payload.tools = [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }];\n }\n const res = await fetch(`${provider.baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n // Anthropic's official opt-in to allow calls straight from the browser.\n 'anthropic-dangerous-direct-browser-access': 'true',\n },\n body: JSON.stringify(payload),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const blocks = Array.isArray(body?.content) ? body.content : [];\n const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('\\n');\n if (!text) throw new Error(`${provider.label}: empty response`);\n // Web sources come back as citation objects attached to text blocks.\n const webSources = [];\n const seen = new Set();\n for (const b of blocks) {\n for (const c of b.citations || []) {\n if (c.url && !seen.has(c.url)) {\n seen.add(c.url);\n webSources.push({ title: c.title || c.url, url: c.url });\n }\n }\n }\n return { text, webSources };\n}\n\n// Run one analysis. `system` + `user` are plain strings.\n// Returns { text, webSources:[{title,url}] } — webSources is [] unless the provider\n// searched the web (Perplexity always; Anthropic when `webResearch` is set).\nexport async function runChat({ provider, model, apiKey, system, user, maxTokens = 1600, temperature = 0.3, webResearch = false }) {\n const p = getProvider(provider);\n if (!p) throw new Error(`Unknown provider: ${provider}`);\n if (!apiKey) throw new Error('No API key set');\n if (webResearch && !p.webSearch) {\n // Refuse rather than fake it: prompting a no-search API to \"search the web\"\n // produces invented sources — the exact failure this feature exists to prevent.\n throw new Error(`${p.label} cannot search the web from its API. Pick Perplexity or Anthropic for Deep research.`);\n }\n const args = { model, apiKey, system, user, maxTokens, temperature, webResearch };\n return p.type === 'anthropic' ? runAnthropic(p, args) : runOpenAICompat(p, args);\n}\n"}, "26d10ec4199d9c4c0b62cb554b985cbc7c4fdab4": {"t": 1785765784.0530052, "n": 7149, "h": "2292f2f7ea3038b7", "c": "// BYOK (Bring-Your-Own-Key) LLM client for the Analysis Studio (/analyze).\n//\n// The user picks a provider + model and pastes THEIR OWN key. The key lives only\n// in the browser (see utils/byok.js) and the chat call goes browser → provider\n// directly — our servers never see, log, or store it.\n//\n// Two code paths:\n// - OpenAI-compatible chat/completions: OpenAI, DeepSeek, Gemini (compat), OpenRouter\n// - Anthropic Messages API: separate shape + the direct-browser-access header\n//\n// ⚠️ Direct browser calls depend on each provider's CORS policy. Where a provider\n// blocks browser origins the fetch will fail with a CORS/network error — surfaced\n// honestly to the user. (The eventual fallback is a no-store pass-through Lambda.)\n\n// `webSearch` capability per provider:\n// 'always' — every call searches the web natively (Perplexity sonar)\n// 'tool' — search happens only when we attach the provider's web-search tool (Anthropic)\n// absent — the API cannot search; Deep-research mode is disabled for it (honest gating,\n// never silently degraded: a \"search the web\" prompt to a no-search API would\n// make the model FAKE having searched).\nexport const PROVIDERS = [\n {\n id: 'deepseek',\n label: 'DeepSeek',\n type: 'openai',\n baseUrl: 'https://api.deepseek.com/v1',\n keyHint: 'sk-…',\n models: ['deepseek-chat', 'deepseek-reasoner'],\n },\n {\n id: 'perplexity',\n label: 'Perplexity (built-in web search)',\n type: 'openai',\n baseUrl: 'https://api.perplexity.ai',\n keyHint: 'pplx-…',\n // Sonar models always search the web and return `citations`/`search_results`.\n models: ['sonar-pro', 'sonar', 'sonar-reasoning-pro', 'sonar-deep-research'],\n webSearch: 'always',\n },\n {\n id: 'openai',\n label: 'OpenAI',\n type: 'openai',\n baseUrl: 'https://api.openai.com/v1',\n keyHint: 'sk-…',\n models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini'],\n },\n {\n id: 'gemini',\n label: 'Google Gemini',\n type: 'openai',\n baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',\n keyHint: 'AIza…',\n models: ['gemini-2.5-flash', 'gemini-2.5-pro'],\n },\n {\n id: 'openrouter',\n label: 'OpenRouter',\n type: 'openai',\n baseUrl: 'https://openrouter.ai/api/v1',\n keyHint: 'sk-or-…',\n models: [\n 'deepseek/deepseek-chat',\n 'openai/gpt-4o',\n 'anthropic/claude-sonnet-4.6',\n 'google/gemini-2.5-flash',\n ],\n },\n {\n id: 'anthropic',\n label: 'Anthropic (Claude)',\n type: 'anthropic',\n baseUrl: 'https://api.anthropic.com/v1',\n keyHint: 'sk-ant-…',\n // Current Claude model IDs (confirm against the claude-api reference before shipping).\n models: ['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-haiku-4-5-20251001', 'claude-fable-5'],\n webSearch: 'tool',\n },\n];\n\nexport function getProvider(id) {\n return PROVIDERS.find((p) => p.id === id) || null;\n}\n\n// Normalize the provider's web-source metadata to [{ title, url }].\nfunction openAIWebSources(body) {\n // Perplexity: `search_results` [{title,url,date}] (richer) and/or `citations` [url,…].\n if (Array.isArray(body?.search_results) && body.search_results.length) {\n return body.search_results\n .map((r) => ({ title: r.title || r.url, url: r.url }))\n .filter((r) => r.url);\n }\n if (Array.isArray(body?.citations) && body.citations.length) {\n return body.citations.filter(Boolean).map((u) => ({ title: u, url: u }));\n }\n return [];\n}\n\nasync function runOpenAICompat(provider, { model, apiKey, system, user, maxTokens, temperature }) {\n const res = await fetch(`${provider.baseUrl}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n max_tokens: maxTokens,\n temperature,\n messages: [\n { role: 'system', content: system },\n { role: 'user', content: user },\n ],\n }),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const text = body?.choices?.[0]?.message?.content;\n if (!text) throw new Error(`${provider.label}: empty response`);\n return { text, webSources: openAIWebSources(body) };\n}\n\nasync function runAnthropic(provider, { model, apiKey, system, user, maxTokens, temperature, webResearch }) {\n const payload = {\n model,\n max_tokens: maxTokens,\n temperature,\n system,\n messages: [{ role: 'user', content: user }],\n };\n // Anthropic's official server-side web-search tool — the model searches and the\n // response carries real source citations. Only attached in Deep-research mode.\n if (webResearch) {\n payload.tools = [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }];\n }\n const res = await fetch(`${provider.baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n // Anthropic's official opt-in to allow calls straight from the browser.\n 'anthropic-dangerous-direct-browser-access': 'true',\n },\n body: JSON.stringify(payload),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const blocks = Array.isArray(body?.content) ? body.content : [];\n const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('\\n');\n if (!text) throw new Error(`${provider.label}: empty response`);\n // Web sources come back as citation objects attached to text blocks.\n const webSources = [];\n const seen = new Set();\n for (const b of blocks) {\n for (const c of b.citations || []) {\n if (c.url && !seen.has(c.url)) {\n seen.add(c.url);\n webSources.push({ title: c.title || c.url, url: c.url });\n }\n }\n }\n return { text, webSources };\n}\n\n// Run one analysis. `system` + `user` are plain strings.\n// Returns { text, webSources:[{title,url}] } — webSources is [] unless the provider\n// searched the web (Perplexity always; Anthropic when `webResearch` is set).\nexport async function runChat({ provider, model, apiKey, system, user, maxTokens = 1600, temperature = 0.3, webResearch = false }) {\n const p = getProvider(provider);\n if (!p) throw new Error(`Unknown provider: ${provider}`);\n if (!apiKey) throw new Error('No API key set');\n if (webResearch && !p.webSearch) {\n // Refuse rather than fake it: prompting a no-search API to \"search the web\"\n // produces invented sources — the exact failure this feature exists to prevent.\n throw new Error(`${p.label} cannot search the web from its API. Pick Perplexity or Anthropic for Deep research.`);\n }\n const args = { model, apiKey, system, user, maxTokens, temperature, webResearch };\n return p.type === 'anthropic' ? runAnthropic(p, args) : runOpenAICompat(p, args);\n}\n"}, "c1ef5f6cab2719335ddd046022ea6c315a31859f": {"t": 1785765785.587867, "n": 4558, "h": "b38577727861f559", "c": "// BYOK (Bring-Your-Own-Key) LLM client for the Analysis Studio (/analyze).\n//\n// The user picks a provider + model and pastes THEIR OWN key. The key lives only\n// in the browser (see utils/byok.js) and the chat call goes browser → provider\n// directly — our servers never see, log, or store it.\n//\n// Two code paths:\n// - OpenAI-compatible chat/completions: OpenAI, DeepSeek, Gemini (compat), OpenRouter\n// - Anthropic Messages API: separate shape + the direct-browser-access header\n//\n// ⚠️ Direct browser calls depend on each provider's CORS policy. Where a provider\n// blocks browser origins the fetch will fail with a CORS/network error — surfaced\n// honestly to the user. (The eventual fallback is a no-store pass-through Lambda.)\n\nexport const PROVIDERS = [\n {\n id: 'deepseek',\n label: 'DeepSeek',\n type: 'openai',\n baseUrl: 'https://api.deepseek.com/v1',\n keyHint: 'sk-…',\n models: ['deepseek-chat', 'deepseek-reasoner'],\n },\n {\n id: 'openai',\n label: 'OpenAI',\n type: 'openai',\n baseUrl: 'https://api.openai.com/v1',\n keyHint: 'sk-…',\n models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini'],\n },\n {\n id: 'gemini',\n label: 'Google Gemini',\n type: 'openai',\n baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',\n keyHint: 'AIza…',\n models: ['gemini-2.5-flash', 'gemini-2.5-pro'],\n },\n {\n id: 'openrouter',\n label: 'OpenRouter',\n type: 'openai',\n baseUrl: 'https://openrouter.ai/api/v1',\n keyHint: 'sk-or-…',\n models: [\n 'deepseek/deepseek-chat',\n 'openai/gpt-4o',\n 'anthropic/claude-sonnet-4.6',\n 'google/gemini-2.5-flash',\n ],\n },\n {\n id: 'anthropic',\n label: 'Anthropic (Claude)',\n type: 'anthropic',\n baseUrl: 'https://api.anthropic.com/v1',\n keyHint: 'sk-ant-…',\n // Current Claude model IDs (confirm against the claude-api reference before shipping).\n models: ['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-haiku-4-5-20251001', 'claude-fable-5'],\n },\n];\n\nexport function getProvider(id) {\n return PROVIDERS.find((p) => p.id === id) || null;\n}\n\nasync function runOpenAICompat(provider, { model, apiKey, system, user, maxTokens, temperature }) {\n const res = await fetch(`${provider.baseUrl}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n max_tokens: maxTokens,\n temperature,\n messages: [\n { role: 'system', content: system },\n { role: 'user', content: user },\n ],\n }),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const text = body?.choices?.[0]?.message?.content;\n if (!text) throw new Error(`${provider.label}: empty response`);\n return text;\n}\n\nasync function runAnthropic(provider, { model, apiKey, system, user, maxTokens, temperature }) {\n const res = await fetch(`${provider.baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n // Anthropic's official opt-in to allow calls straight from the browser.\n 'anthropic-dangerous-direct-browser-access': 'true',\n },\n body: JSON.stringify({\n model,\n max_tokens: maxTokens,\n temperature,\n system,\n messages: [{ role: 'user', content: user }],\n }),\n });\n const body = await res.json().catch(() => null);\n if (!res.ok) {\n const msg = body?.error?.message || body?.message || JSON.stringify(body) || res.statusText;\n throw new Error(`${provider.label} ${res.status}: ${msg}`);\n }\n const text = Array.isArray(body?.content)\n ? body.content.filter((b) => b.type === 'text').map((b) => b.text).join('\\n')\n : null;\n if (!text) throw new Error(`${provider.label}: empty response`);\n return text;\n}\n\n// Run one analysis. `system` + `user` are plain strings; returns the model's text.\nexport async function runChat({ provider, model, apiKey, system, user, maxTokens = 1600, temperature = 0.3 }) {\n const p = getProvider(provider);\n if (!p) throw new Error(`Unknown provider: ${provider}`);\n if (!apiKey) throw new Error('No API key set');\n const args = { model, apiKey, system, user, maxTokens, temperature };\n return p.type === 'anthropic' ? runAnthropic(p, args) : runOpenAICompat(p, args);\n}\n"}}, "ARCHITECTURE.md": {"80945bd7c0196bbb93d47fc1556a9653af98377f": {"t": 1785746575.4284785, "n": 185070, "h": "4fd91fb83ca81f0d", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-29 (refresh: **SILENT OUTPUT-TRUNCATION IN `newsSystemsAnalysis` — CEILING RAISED**. The 07-29 run went `7 generated, 3 failed` (`Unterminated string in JSON at position 10929`, Iran + Ukraine): `MAX_TOKENS` was **unset** → code default **3000**, and the graphs had outgrown it. Measured by replaying the real prompts at an 8000 ceiling — Iran **3214** tokens, Ukraine **3062**, US **3038** — i.e. the whole cohort sat in a 3038–3214 band *straddling* the limit, so which countries failed was luck (US passed that morning at 3038 and was already over). Fix = env **`MAX_TOKENS=6000`** (~87% headroom; `max_tokens` is a ceiling, not spend); re-run confirmed Iran + Ukraine now generate. **Silent class again:** caught per-country → exit 200 → CloudWatch `Errors` `0.0`, and neither `newsFreshnessMonitor` (topics only) nor `newsModelGuard` #30 (model ids only) covers it — same family as the 06-01 truncation stall. Also corrected: **`newsPostDevTo` #7 is the sole producer of the `/daily` Daily Brief** (a DeepSeek `callGrok` path entirely separate from its OpenRouter Dev.to article) — the doc had described only the Dev.to half, so nothing pointed a triager at this Lambda when `/daily` empties. Prior: **DEEPSEEK `deepseek-chat` RETIREMENT — FLEET MIGRATED TO V4**. `deepseek-chat` was retired by DeepSeek **2026-07-24**, silently breaking all 11 DeepSeek Lambdas for ~36h (most swallow the LLM error and exit 200, so CloudWatch showed 0 errors — only the freshness monitor's stale-content alert surfaced it). **Fixed 2026-07-26:** added `thinking: { type: 'disabled' }` to each function's DeepSeek request body (V4 defaults to thinking-mode → burns `max_tokens` on invisible `reasoning_content` → truncated/empty output — proven live: v4-flash without the patch clustered only 1 topic at `finish_reason=length`), then flipped `GROK_MODEL` per an intent-based split — **v4-pro** for the 3 open-analytical functions (`newsCountryIntelligence`, `newsSystemsAnalysis`, `newsEconomicImpact`), **v4-flash** for the mechanical/constrained/judge-protected/high-volume rest (`newsInvokeGemini`, `NewsProjectInvokeAgentLambda`, `newsDriftCorrector`, `newsPredictionResolver`, `newsWeeklyBrief`, `newsWeeklyMarkets`-producer, `newsPairIntelligence`, `newsImpactAudit`). Pipeline verified live end-to-end (13 topics → 39 AI items → swap). The `thinking` patch is now **ported into the repo `src/index.js` of all 10 on-main functions** (2026-07-27) so a future zip-from-repo deploy won't regress it. ✅ PPA/property project (same account) also migrated — `PPAcomputeWardProfiles-dev` + `PPAfetchMarketNews-dev` both verified on `LLM_MODEL=deepseek-v4-flash` (2026-07-27). Prior: **AGENT-REVIEW STALENESS AUDIT** — a 5-auditor parallel sweep verified the whole doc against live AWS (`AGENT_REVIEW_METHOD.md`). Fixes landed: (1) **`newsBreakingAlert` #21 is DEPLOYED + scheduled + `DRY_RUN=false`**, not \"BUILT — NOT DEPLOYED\" (still human-confirm-before-send); (2) **4 of 7 EventBridge *Scheduler* entries run `Asia/Tokyo`, not UTC** — `InvokeLinkedIn`/`LinkedinThreadsDaily`/`Fact` corrected (`InvokeDev` was already right); the \"All times UTC\" header was wrong; (3) **`newsPairIntelligence` #8 is on the ENABLED weekly rule `TriggerPairIntelligenceWeekly`**, not manual-only; (4) **Common Mistakes #7 CORS list corrected** — only `newsPolarBilling`+`newsAnalyze` own CORS in code (Function-URL CORS empty); `newsSavedItems`+`newsRecommend` do the opposite (populated Function-URL CORS, no ACAO in code) — clearing theirs would break them; (5) **a Signal-API + impact-first ingest subsystem** (`newsSignals`/`newsImpactAudit`/`newsGdacsIngest`/`newsGdeltConflict` + 6 DDB tables) is live in prod from the unmerged `signal-api` branch — now documented as \"Prod infra NOT on `main`\"; (6) stale counts: proxy actions ~27→~33, components ~64→67, atoms 17→19, hooks 32→34; `newsPostLinkedin` env `SOCIAL_POSTS_TABLE`→deployed `LINKEDIN_POSTS_TABLE`. Backend #10–20 + frontend routes/components/hooks mappings verified CLEAN. ~~Note to reconcile: CLAUDE.md's Dual-CORS bullet + memory `feedback_lambda_function_url_cors` still carry the old 4-function CORS list~~ — **RESOLVED (verified 2026-07-29): both already carry the corrected list** (only `newsPolarBilling`+`newsAnalyze` own CORS in code / empty Function-URL CORS; `newsSavedItems`+`newsRecommend` the opposite), re-confirmed against live `get-function-url-config`. Prior: **ANALYSIS STUDIO — VISUAL-BLOCK RELIABILITY FIX + QWEN PROVIDER** [[project_analysis_studio]]. 07-10 [`4193610`, `QWEN_AND_VISUAL_BLOCK_PLAN.md`]: a **qwen3.7-max vs deepseek-v4-pro bake-off** (same stories/lenses, shipped pipeline) exposed the real \"where's the graph?\" cause — DeepSeek (the member-path model) emitted the `gp-struct` block in only **1/4** runs (writes the scenario prose but skips the machine-readable block), so the 07-10 ScenarioBars/IndicatorMatrix/RippleTable rarely rendered for members; Qwen emitted 4/4. **Fix (prompt-only, `analysisPrompt.js`):** embedded a fully-filled JSON template in SYSTEM_PROMPT + a mandatory tail reminder inside each lens task (where the model finishes) + removed the easy \"omit the whole block\" exit for scenario/economic lenses → valid-block emission **1/4 → 8/8** live (`scratchpad/emit-test.mjs`); honesty intact — `validateStruct` still strips any figure not literally in the prose, so all 8 validated with zero fabrication. `newsAnalyze` Lambda re-synced via the **patched-deployed-zip** method (Node made deployed SYSTEM_PROMPT byte-equal to the frontend, `FE===BE` 5278 chars; deployed bytes verified: template present, omit-exit gone, `creditBalance`=0, `MAX_TOKENS`=2400; lens-task tail reminders ride in via the frontend-built `body.user`). **Bake-off verdict:** DeepSeek stays the member-path default (tighter/MECE probability bands, deeper prose, always states confidence); **Qwen3.7-max added as a faster (~36%) BYOK option** — but Alibaba's international keys are **WORKSPACE-scoped (`sk-ws-…`)** and 401 the generic `dashscope-intl` host, needing `https://<WorkspaceId>.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1` (Tokyo; verified with `ws-gl08109galyxzzh3`), so a per-provider **base-URL override** was added (`allowBaseUrlOverride` on qwen; `runChat`/`runOpenAICompat` take optional `baseUrl`→`baseUrl||provider.baseUrl`; ProviderModal optional \"Endpoint URL\" field; `byok.js` persists it back-compat) — live-tested end-to-end through real `runChat` (override→200, default→401). Separately (same day, prod-config only): the member path itself was switched to **`deepseek-v4-pro`** (`ANALYZE_DAILY_CAP` 50→100, `Timeout` 30s→120s), which required patching `runDeepSeek` to send `thinking:{type:'disabled'}` — v4-pro's default thinking mode otherwise burns the whole `MAX_TOKENS` budget and returns empty output — and surfaced a lesson that a timed-out synchronous Function-URL Lambda returns no-ACAO, which looks like CORS but isn't. Prior: **ANALYSIS STUDIO SURFACED + PROFESSIONAL STRUCTURE**. 07-09 [`8cf2b29`, `ANALYZE_SURFACING_PLAN.md`]: `/analyze` was discoverable from ONE nav link (~2 runs/30d = discovery gap, not quality — engine live-tested 0/3 fabrication, and a manual web truth-check found every auditor flag was a FALSE POSITIVE vs ground truth); surfaced via a per-story **\"Analyze →\"** pill on Home (4th control beside Summary/Predict/Trace), a Home hero value-prop line, ThreadPage **\"Analyze this arc →\"** (deep-links `thread.entries[0].topicId`), an `/economy` cross-link, and a new **`?stories=<id>[,…]` preselect** in `AnalysisStudio.jsx` (seeds once on topics-load, unknown ids silently dropped). 07-10 [`c429931`, `ANALYSIS_PRO_STRUCTURE_PLAN.md`]: output upgraded to ICD-203/desk-note packaging — **Key Judgments box** (BLUF bullets), the **ICD-203 probability yardstick** verbatim (\"very likely (80–95%)\"), **confidence as a separate justified axis**, a consolidated **`| Indicator to watch | Confirms | Kills |` table** on the scenario lens, and a fenced **`gp-struct` JSON block** (a machine-readable INDEX of the prose — never new content) parsed by new `utils/analysisStruct.js` (truncation-safe; drops scenarios whose probability digits aren't literally in the prose) → rendered by `atoms/AnalysisVisuals.jsx` (**ScenarioBars/IndicatorMatrix/RippleTable**, pure CSS). Validator + Markdown always receive the STRIPPED prose. Review caught 4 pre-deploy bugs: `newsAnalyze`'s server-pinned prompt had **DRIFTED** (missing all 06-13 upgrades — members got a weaker analyst than BYOK; now synced verbatim), gp-struct truncation at 1600 tokens (guided maxTokens→2400, Studio+eval+Lambda env), and two validator false-positives from the new structure (comparison-operator thresholds `>50%`; scenario-heading ranges — estimative look-behind 32→48; goldens added). **⚠️ prod `newsAnalyze` deploy = PROMPT-PATCH of the downloaded DEPLOYED (pre-credits 06-16) zip — the repo file carries the PARKED credits code and must NOT reach prod until `PROD_CREDITS_NEXT_STEPS.md` runs.** Prior: **SECOND PREDICTION RESOLUTION RUN + DRIFT-EMAIL PER-AXIS VIEW + FOLLOWBUTTON ON THE LEDGER** [[project_prediction_methodology_v1]] [[project_scoring_model_v2]]. The 07-08 agent-run resolution sweep scored **all 21 due triggers** [incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers from the 07-07 run, cleared on recheck]. **`/track-record` now shows resolved 35 (was 15), fired 9 (was 4), Brier 0.157 (was 0.084)**; calibration buckets `0-20%`→stated ~17%/fired 9% (n11) · `20-40%`→stated ~20%/fired 9% (n11) · `60-80%`→stated ~65%/fired 54% (n13). Brier rose on purpose: run-1 was flattered by easy low-probability tails; run-2 added real high-stakes calls with genuine misses — the honest direction. The 65% bucket runs ~10pts hot (54% actual) but the curve is monotonic (9%→9%→54%). Cadence confirmed weekly agent-in-session. Prior (07-08, both already on main): **drift-alert email → per-axis view** [`0ec494f`, `SCORING_MODEL_V2_PLAN.md` §12] — `newsDriftCorrector` now embeds `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel` onto each new `DRIFT#`/`DRIFTLOG#` country note; `renderDriftEmail.js` renders a degradation ladder (currentDimensions → Level B 4-axis scorecard `HIGH · Humanitarian 82` + `⚠ N/4 elevated` → changeDimensions → Level A per-axis pills → legacy scalar); not retroactive. **FollowButton surfaced on the `/track-record` corrections ledger** [`b455ee1`, `FOLLOW_SURFACING_PLAN.md`] — the member country-follow control (previously CountryPage-only) now also renders as a sibling of the name link on each **country-scope** ledger row, the highest-intent \"get the next change emailed\" moment; management still lives at `/account?tab=notifications`. Deferred surfaces: CountryWhatChanged band (redundant), ThreadPage (multi-country ambiguity), countries-index (nesting/grid). Prior: **FIRST PREDICTION RESOLUTION RUN DONE + G6 SPORTS-SCOPE GATE LIVE** [[project_prediction_methodology_v1]]. The accountability record is no longer empty: the first agent-run resolution loop (`predictions/V1_RESOLUTION_RUNBOOK.md`) scored **16 of 21 due triggers** — 3 parallel blind web-grounded verifiers → 2nd independent blind pass on every `fired` → operator ~10% spot-check → dry-run → `resolve-v1-write.js --commit`. **`/track-record` now shows Brier 0.084**, resolved 15 (fired 4 · not_fired 11) + 1 unclear, calibration buckets 0-20%→0% (n6) · 20-40%→0% (n4) · 60-80%→80% (n5) [caveat: n tiny, 11/15 were low-p tails correctly not firing = easy points]; 5 triggers deferred (window still open on run day — Le Pen ruling ×3, INDOPACOM, Andersen). Deadlines arrived earlier than the ~07-11 estimate (short-horizon triggers). **New capture gate G6** [`NewsProjectInvokeAgentLambda/src/lib.js`, deployed to `-dev` + deployed-bytes verified; source `91e069e` + deploy `73e490d`]: drops **pure sporting-result** triggers — a match/tournament outcome or on-field participation carries no geopolitical/economic/institutional signal (the remit). Rule = drop only when BOTH a sporting-competition **context** regex AND a **result/participation** regex match, so sport-**adjacent** governance triggers survive (no result verb: \"Belgium files a FIFA protest\", \"CAS grants an interim measure\" pass). **Corpus-validated: across all 1038 live v1 triggers G6 drops exactly the 2 Trump–FIFA/Balogun box-score triggers, 0 false positives**; `node --test` 21/21. NOT retroactive (already-published sport triggers stay scored); scope decision was fix-forward at the trigger layer, NOT an ingest ban. Methodology disclosure live on `/track-record`. Cadence = weekly agent-in-session. Prior: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- **DeepSeek V4 (post-2026-07-26 migration — `deepseek-chat` is retired; all send `thinking:{type:'disabled'}`):**\n - **`deepseek-v4-pro`** (deeper reasoning; the open-analytical reads): `newsCountryIntelligence`, `newsSystemsAnalysis`, `newsEconomicImpact`\n - **`deepseek-v4-flash`** ($0.14/M in · $0.28/M out; mechanical/constrained/judge-protected/high-volume): `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsDriftCorrector`, `newsPredictionResolver`, `newsWeeklyBrief`, `newsWeeklyMarkets` (producer call), `newsPairIntelligence`, `newsImpactAudit`\n - `newsPostDevTo` uses **OpenRouter** (`deepseek/deepseek-v4-flash:free`), not the direct DeepSeek API — see #7\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek v4-flash** (migrated 2026-07-26 from the retired `deepseek-chat`; `api.deepseek.com`, `thinking:disabled`). On the weekly rule `TriggerPairIntelligenceWeekly` + ad-hoc manual invokes.\n- `newsAnalyze` → **DeepSeek v4-pro** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=100`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). **DEPLOYED + scheduled** (`TriggerBreakingAlert` 4-hourly, `DRY_RUN=false`; proposes only, human confirms before send) — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~33 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek v4-flash** (migrated 2026-07-26 from the retired `deepseek-chat`; `thinking:disabled`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-v4-flash` in production (was `deepseek-chat` until the 2026-07-26 retirement migration). Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check · **G6 capture-scope — drops pure sporting results, added 2026-07-07**) before it can enter the immutable log — a malformed or out-of-scope trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. G6 fires only when a trigger has BOTH a sporting-competition context AND a result/participation verb (so sport-adjacent governance triggers survive); corpus-validated 2/1038, 0 false positives. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (21/21, uses the real 2026-07-04-pilot defect triggers + the 2026-07-07 Balogun box-score triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-v4-flash` in production (was `deepseek-chat` until the 2026-07-26 retirement migration; `thinking:disabled` in the request body). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. **Two agent-run resolution sweeps done (07-07, 07-08) → live: resolved 35, fired 9, Brier 0.157, calibration buckets populated** (was honest-empty before 07-07) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)`, **timezone `Asia/Tokyo`** (verified 2026-07-25 — the scheduler is NOT in UTC, same as `newsPostDevTo` #7). Fires at :20 past JST hours 0/3/6/… = **15/18/21/00/… UTC**.\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `LINKEDIN_POSTS_TABLE` (=`NewsProject-linkedin-posts`; the deployed var is **not** `SOCIAL_POSTS_TABLE` — code reads `SOCIAL_POSTS_TABLE || LINKEDIN_POSTS_TABLE` so the fallback wins), `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to) — **and, despite the name, is also the sole producer of the Daily Intelligence Brief behind `/daily`.**\n\n> ⚠️ **Misleading name — two independent jobs on two different providers** (corrected 2026-07-29; the doc previously described only the Dev.to half). `/daily` has no other producer, so a failure here silently empties the Daily Brief page while the Dev.to post is unaffected (and vice versa). When triaging `/daily`, look at *this* Lambda, not a \"daily brief\" function — there isn't one.\n> - **`generateAndStoreDailyBrief` → `callGrok`** uses **DeepSeek** via `GROK_MODEL`/`GROK_API_URL`/`XAI_API_KEY` and writes `DAILY_BRIEF#{dateKey}` to `SUMMARIZE_PREDICT_TABLE`. Migrated to `deepseek-v4-flash` + `thinking:{type:'disabled'}` on 2026-07-27 (main `c232624`) — it was the last straggler on the retired `deepseek-chat` alias.\n> - **The Dev.to article** uses **OpenRouter** via `AI_MODEL` — a completely separate call path and key.\n> - The stored brief carries a `model` field sourced from `GROK_MODEL`, so briefs written before 07-27 are labelled `deepseek-chat`.\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. **Builds + stores the Daily Brief** (`DeepSeek`, 50 archive entries + thread/country enrichment) → `DAILY_BRIEF#{dateKey}` / `DAILY_BRIEF`, 90-day TTL. Served by the `daily_brief` action → `/daily`\n3. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n4. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n5. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `XAI_API_KEY` / `GROK_MODEL` (=`deepseek-v4-flash`) / `GROK_API_URL` (the **daily-brief** path — legacy names holding DeepSeek values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPairIntelligenceWeekly` — **ENABLED** — `cron(0 8 ? * MON *)` (Mondays 08:00 UTC; **corrected 2026-07-25 — was documented as manual-only**). Also invocable manually with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for the default 10 pairs.\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** the source default still reads Grok, but the **deployed** function is on DeepSeek (`GROK_MODEL=deepseek-v4-flash` since the 2026-07-26 retirement migration — was `deepseek-chat`; `GROK_API_URL=https://api.deepseek.com/chat/completions`; request body sends `thinking:disabled`). Runs weekly via `TriggerPairIntelligenceWeekly` (see trigger above) plus ad-hoc manual invokes.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`, **`MAX_TOKENS` (=`6000`; code default `3000` — see below)**\n\n> ⚠️ **Output-truncation ceiling (raised 2026-07-29).** `MAX_TOKENS` was **unset**, so this ran on the code default **3000** — and the graphs outgrew it. On 07-29 the run went `7 generated, 3 failed` with `Failed to parse Grok response: Unterminated string in JSON at position 10929` for Iran/Ukraine. **Measured** (replaying the real prompts at an 8000 ceiling): Iran needs **3214** completion tokens, Ukraine **3062**, United States **3038** — the whole 12-country cohort had drifted into a 3038–3214 band straddling the 3000 limit, so which countries failed was luck, not content (US passed that day at 3038 and was already over). Truncation arithmetic confirms it exactly: 12032 full chars − 10929 clipped = 1103 chars ≈ the 214-token overshoot. Fix = env `MAX_TOKENS=6000` (~87% headroom over the measured peak); `max_tokens` is a **ceiling, not spend**, so cost is unchanged. **Silent class:** failures are caught per-country and the function exits 200, so CloudWatch `Errors` stayed `0.0` — and neither `newsFreshnessMonitor` (watches `topics` only) nor `newsModelGuard` (#30, model ids only) covers this. Same family as the 06-01 `newsInvokeGemini` JSON-truncation stall; if graphs later exceed ~20 threads, port that function's `salvageTruncatedTopics()` pattern here (this one's `JSON.parse` has no salvage branch).\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` — DEPLOYED + SCHEDULED (proposal mode; `DRY_RUN=false`)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerBreakingAlert` — **ENABLED** — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis).\n\n> ⚠️ **Doc corrected 2026-07-25 (agent-review staleness audit):** this function was long documented as \"BUILT — NOT DEPLOYED (dry-run)\". Live AWS shows it is **deployed** (Timeout 120s / 256MB), driven by the **ENABLED** rule above, wired to the `GlobalPerspectiveBreakingAlerts` table, with **`DRY_RUN=false`**. What is still true is the **human-in-the-loop / never-auto-sends** design: even at `DRY_RUN=false` the code only writes a `status:'proposed'` row and logs \"awaiting human confirmation before send\" — the actual email send lives in `newsEmailSender` breaking mode (#29), gated on a human `status:'confirmed'`. So \"proposes automatically, sends only after human confirm,\" **not** \"dry-run/un-deployed.\"\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` runs live on its 4-hourly schedule writing structured `status:'proposed'` records; legacy confirmed records render via the saved-email-text fallback in `get_alert`.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (deployed, verified 2026-07-25):** `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE` (=`SummarizeAndPredict`), `BREAKING_ALERTS_TABLE` (=`GlobalPerspectiveBreakingAlerts`), `RESEND_API_KEY` (set), `SITE_URL`, `DRY_RUN` (=`false`), plus `ALERT_EMAIL_FROM` (=`onboarding@resend.dev`) and `ALERT_EMAIL_TO` (operator address). `SIGNIFICANCE_THRESHOLD`/`DEDUPE_DAYS` are **not** set → code defaults only. No Function URL (this is a scheduled Lambda, not URL-invoked).\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars (deployed, verified 2026-07-25):** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `USERS_DDB_TABLE` (=`GlobalPerspectiveUserTable`, added for the follow-country tier check), `FIREBASE_PROJECT_ID`. (`BREAKING_ALERTS_TABLE` and `SITE_URL` are **not** set in the deployed env — code falls back to literals.) **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered. Country-note fields: `changeScore`/`changeLevel`/`changeDimensions{axis:{from,to,delta}}` (what moved) + `priorHeadline`/`currentHeadline` + `triggerEvent`/`whyChanged`/`noSingleDriver`, **and (scoring-v2 Phase D-email, 2026-07-08) the current standing `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel`** for the email's Level-B scorecard (the vector was already loaded for `changeDimensions`; not backfilled — new notes only).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**⚠️ DEPLOYED ≠ REPO (2026-07-10):** prod still runs the **pre-credits 06-16 code** (gate = `ANALYZE_DAILY_CAP=50`) with the **P1 professional-structure SYSTEM_PROMPT patched in** (2026-07-10: downloaded the deployed zip, patched ONLY the prompt block, redeployed; `MAX_TOKENS` env 1600→2400 for the longer structured output). The server-pinned prompt is now mirrored **verbatim** from the frontend's `utils/analysisPrompt.js` — it had silently drifted (missing all 06-13 upgrades, so member runs got a weaker analyst than BYOK). **Re-synced again 2026-07-10 (`4193610`)** with the **gp-struct visual-block reliability fix** (embedded JSON template + emission mandate — raised valid-block emission on the DeepSeek member path from 1/4 to 8/8, so the ScenarioBars/etc. now actually render for members); the lens-task tail reminders reach the model via the frontend-built `body.user` (the Lambda only holds `SYSTEM_PROMPT`), so syncing that array was sufficient. Same patched-zip method: `FE===BE` verified byte-for-byte (5278 chars). **Never zip the repo `src/index.js` to prod until `PROD_CREDITS_NEXT_STEPS.md` runs** — it carries the parked credits code, and deploying it without the env-ordering steps can mis-grant orders. **Model switch + timeout fix (2026-07-10, prod-config only):** the member-path model was switched `deepseek-chat` (V4 Flash) → **`deepseek-v4-pro`** (env `GROK_MODEL`, the bake-off's stronger analyst), with a required code patch (same patched-deployed-zip method) sending `thinking:{type:'disabled'}` in `runDeepSeek` — v4-pro defaults to thinking mode, which without this burns the whole `MAX_TOKENS` budget on `reasoning_content` and returns **empty output** (`len=0, finish_reason=length`, proven live before the fix; code patch deployed before the env flip so there was no broken window). `ANALYZE_DAILY_CAP` raised 50→100 and `Timeout` raised 30s→120s (v4-pro takes ~40–60s; the old 30s timeout surfaced in-browser as a fake-CORS **\"Failed to fetch\" / \"No 'Access-Control-Allow-Origin' header\"** error — the Function-URL CORS config was correctly empty throughout, so this was NOT the dual-CORS trap: on a synchronous Function-URL Lambda, a timed-out invocation returns a bare platform error with no CORS headers, which reads as CORS but is actually a timeout).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live). **Per-axis view — scoring-v2 Phase D-email (2026-07-08, `SCORING_MODEL_V2_PLAN.md` §12):** `renderDriftEmail` renders a degradation ladder — `note.currentDimensions` → **Level B** compact scorecard (`HIGH · Humanitarian 82` + 4-axis row + `⚠ N/4 elevated`); else `changeDimensions` → **Level A** per-axis change pills; else the legacy scalar. No frontend-util import (reads the note's derived headline fields; breadth flag inline). Not retroactive — Level B only for notes written after the corrector deploy.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n### Prod infra NOT on `main` — Signal-API + impact-first ingest (documented 2026-07-25)\n\nFour Lambdas are **deployed and ENABLED in prod** but their **source lives only on the unmerged `signal-api` branch/worktree**, so they had no entry here. They pair with 4 undocumented DDB tables (below). Flagged by the 2026-07-25 staleness audit; source not on `main` means the deployed-bytes are the only reference — verify against live AWS before changing them. See [[project_signal_api_deployed]], [[project_impact_first_redesign]], `signal-api` branch.\n\n| Function | Trigger (ENABLED) | Config | Role (from deployed env + tables) |\n|---|---|---|---|\n| **`newsSignals`** | `TriggerSignalsBuild` `cron(0 10 * * ? *)` + a public **Function URL** (AuthType NONE, key-gated in code) | 300s / 512MB | Signal-API v1 builder — writes `GlobalPerspectiveSignals` (120d TTL) from `PredictionLog`/`SummarizeAndPredict`/`BreakingAlerts`; `API_KEYS_TABLE=GlobalPerspectiveApiKeys`, `FREE_DELAY_HOURS=24` (paid-tier freshness gate). |\n| **`newsImpactAudit`** | `TriggerImpactAudit` `cron(0 9 * * ? *)` | 120s / 256MB | Coverage dead-man's-switch — DeepSeek audit of whether high-impact events were MISSED; `IMPACT_AUDIT_TABLE`/`INGEST_CAPTURE_TABLE`, SNS→`GlobalPerspectiveAlerts` when misses ≥ `MISS_ALERT_THRESHOLD=2`. |\n| **`newsGdacsIngest`** | `TriggerGdacsIngest` `cron(0 */6 * * ? *)` | 120s / 256MB | GDACS disaster-alert ingest feed → `GlobalPerspectiveGdacsEvents`. |\n| **`newsGdeltConflict`** | `TriggerGdeltConflict` `cron(0 */6 * * ? *)` | 120s / 512MB | GDELT conflict-event ingest feed → `GlobalPerspectiveGdeltConflict`. |\n\n---\n\n### 30. `newsModelGuard` — model-retirement dead-man's-switch (LIVE 2026-07-28)\n**Path:** `amplify/backend/function/newsModelGuard/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerModelGuard` — **ENABLED** — `cron(0 12 * * ? *)` (daily 12:00 UTC). **No LLM** (one cheap authenticated `GET /models`).\n\nThe passive guard against a repeat of the **2026-07-24 `deepseek-chat` retirement** (which became a 36h silent outage because nothing was watching the deadline — see `BACKEND_DEEPSEEK_V4_MIGRATION_PLAN.md`). Daily it `ListFunctions` across the whole account, reads each function's env for model-ID vars (`GROK_MODEL`/`LLM_MODEL`/`MODEL`/`AUDIT_MODEL`/`JUDGE_MODEL`/`PPLX_MODEL`/`AI_MODEL`), and flags any **direct DeepSeek id** (`/^deepseek-/`; the OpenRouter `deepseek/…` namespaced form is skipped) that is either a **deprecated alias** (`deepseek-chat`/`deepseek-reasoner`) or **absent from DeepSeek's live `/models` list** (already retired/invalid). Findings → SNS `GlobalPerspectiveAlerts` with the proven fix recipe; **honest-empty** (silent when all clear). Skips `*-sandbox` functions + `IGNORE_FUNCTIONS` to avoid nagging on throwaway test envs. **Known gap:** env-scan only — a function hitting the retired model via a hardcoded code default (`process.env.X || 'deepseek-chat'`) is invisible here; those were separately hardened to `deepseek-v4-flash` defaults 2026-07-28.\n\n**Key env vars:** `DEEPSEEK_API_KEY` (copied from the fleet — auths the `/models` call), `DEEPSEEK_MODELS_URL` (=`https://api.deepseek.com/models`), `SNS_TOPIC_ARN` (=`GlobalPerspectiveAlerts`), optional `MODEL_VARS`/`DEPRECATED_ALIASES`/`IGNORE_FUNCTIONS`. **Role:** `newsModelGuard-role` (basic-exec logs + `lambda:ListFunctions`/`GetFunctionConfiguration` account-wide read + `sns:Publish` to the alerts topic only).\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch); **`newsModelGuard` (#30)** alerts when any Lambda is left on a deprecated/retired DeepSeek model (the model-retirement dead-man's-switch, born from the 2026-07-24 `deepseek-chat` outage). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d, in-scope — G6 drops pure sporting results from 2026-07-07) and gated-out triggers are recorded in `capture.dropped` with their `gate`; a scenario with no surviving dated trigger carries `scoreable:false`. `finalVerdict`/`confirmedBy:'agent-verified'` written by the Phase-2 agent loop (first run 2026-07-07). Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Populated live by the scheduled detector (`newsBreakingAlert` runs 4-hourly, `DRY_RUN=false`) — see [Lambda #21](#21-newsbreakingalert--deployed--scheduled-proposal-mode-dry_runfalse) + `BREAKING_ALERTS_PLAN.md`.\n\n### Signal-API + ingest tables (`signal-api` branch — live in prod, documented 2026-07-25)\nThese exist in prod DynamoDB but their producers' source is on the unmerged `signal-api` branch (see \"Prod infra NOT on `main`\" under the Lambda inventory). Key schemas confirmed via `describe-table` 2026-07-25:\n\n| Table | Key | Written by | Contents |\n|-------|-----|-----------|----------|\n| `GlobalPerspectiveSignals` | `signal_id` | newsSignals | Signal-API v1 records (120d TTL) |\n| `GlobalPerspectiveApiKeys` | `keyHash` | mint-key.mjs / newsSignals | Signal-API access keys |\n| `GlobalPerspectiveGdacsEvents` | (see describe-table) | newsGdacsIngest | GDACS disaster-alert ingest |\n| `GlobalPerspectiveGdeltConflict` | (see describe-table) | newsGdeltConflict | GDELT conflict-event ingest |\n| `GlobalPerspectiveImpactAudit` | (see describe-table) | newsImpactAudit | Coverage-audit results |\n| `GlobalPerspectiveIngestCapture` | (see describe-table) | ingest feeds | Raw high-impact event capture (audited by newsImpactAudit) |\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G6** (`src/lib.js`; G6 = sporting-result scope, added 2026-07-07) → structured, forward-dated, in-scope, `methodologyVersion:1` triggers; malformed/out-of-scope ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4, runbook `predictions/V1_RESOLUTION_RUNBOOK.md`): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface. **Two runs done (weekly cadence):** 07-07 scored 16 of 21 due triggers (5 deferred, window still open on run day); **07-08 scored all 21 due triggers** (incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers, cleared on recheck). Same-day-deadline learning: verifiers need a \"defer\" option when the event window hasn't closed (not yet folded into the runbook doc). Live totals: resolved 35, fired 9, Brier 0.157 (up from 0.084 after run 1 — run 2 added real high-stakes calls with genuine misses, the honest direction; the 60-80% bucket runs ~10pts hot but the curve stays monotonic).\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n5. **Feedback (Phase 5, DESIGNED 2026-07-08 — data-gated, NOT built)** — `CALIBRATION_DIGEST_PLAN.md`: a weekly `newsCalibrationDigest` Lambda will compact the resolved-trigger trace into one ~200-token `CALIBRATION#DIGEST` row (probability-bucket calibration + per-reference-class fire rates + deterministic bias lines); generation GetItems it and injects a `calibrationBlock` into `buildPredictionPrompt`, stamping `methodologyVersion:2` (the flat-65% v1 era stays as control group). Build gates: CP-1 manual measure at ~50 resolved; CP-2 infra at ~150 resolved. Honesty rule: feedback changes what the model says NEXT, never remaps stored/displayed probabilities. Digest rows must NOT carry `methodologyVersion` (the extract script and `prediction_track_record` filter on it).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-07-25)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. **EventBridge Rules run in UTC; several EventBridge *Scheduler* entries run in `Asia/Tokyo` — do NOT assume UTC for the Scheduler table** (the \"All times UTC\" claim was wrong pre-2026-07-25: 4 of 7 schedulers are JST — see the timezone column below).\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `TriggerModelGuard` | `cron(0 12 * * ? *)` | newsModelGuard (#30 — daily model-retirement guard; SNS-alerts on deprecated/retired DeepSeek model) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n| `TriggerPairIntelligenceWeekly` | `cron(0 8 ? * MON *)` | newsPairIntelligence (#8 — Mondays 08:00 UTC; **NOT manual-only**, corrected 2026-07-25) |\n| `TriggerBreakingAlert` | `cron(15 */4 * * ? *)` | newsBreakingAlert (#21 — 4-hourly, ENABLED, `DRY_RUN=false`) |\n| `TriggerDriftCorrector` | `cron(20 7 * * ? *)` | newsDriftCorrector (#26 — 07:20 UTC daily) |\n| `TriggerWeeklyEmailSend` | `cron(0 14 ? * SUN *)` | newsEmailSender (#29 — weekly digest, ENABLED) |\n| `TriggerBreakingEmailSend` | `rate(15 minutes)` | newsEmailSender (#29 — breaking sends, ENABLED) |\n| `TriggerDriftEmailSend` | `cron(40 7 * * ? *)` | newsEmailSender (#29 — drift alerts, **DISABLED** by design) |\n| `TriggerFreshnessMonitor` | `cron(30 0/2 * * ? *)` | newsFreshnessMonitor (#18) |\n| `TriggerErrorDigest` | `cron(15 0/6 * * ? *)` | newsErrorDigest (#19) |\n| `TriggerSignalsBuild` | `cron(0 10 * * ? *)` | newsSignals (Signal-API — `signal-api` branch, deployed to prod; see \"Prod infra not on `main`\") |\n| `TriggerImpactAudit` | `cron(0 9 * * ? *)` | newsImpactAudit (impact-first — `signal-api` branch) |\n| `TriggerGdacsIngest` | `cron(0 */6 * * ? *)` | newsGdacsIngest (GDACS ingest — `signal-api` branch) |\n| `TriggerGdeltConflict` | `cron(0 */6 * * ? *)` | newsGdeltConflict (GDELT ingest — `signal-api` branch) |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` **TZ Asia/Tokyo** | newsPostLinkedin (:20 past JST hours 0/3/6/… = **15/18/21/00/… UTC** — NOT UTC, corrected 2026-07-25) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` **TZ Asia/Tokyo** | linkedInAutoPost (07:30 / 19:30 **JST** = **22:30 / 10:30 UTC** — corrected 2026-07-25) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` **TZ Asia/Tokyo** | newsCountryFactsUpdater (05:00 **JST** = **20:00 UTC** prior day — corrected 2026-07-25) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual / user-triggered only)\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST / Function URL\n- (`newsPairIntelligence` was listed here as \"manual only\" — **corrected 2026-07-25**: it is now on the ENABLED weekly rule `TriggerPairIntelligenceWeekly`, Mondays 08:00 UTC.)\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, **deployed + scheduled**), newsEmailSender (#29) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~67 component files (in `src/components/` + 19 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then **three** stacked layer lenses: **Today's pulse** (24h fresh-news rings), **Connections** (bilateral arcs from `usePairAnalyses`), **Editorial** (top-5-by-signal numbered pins). Base country fills + two-tier signal markers are always drawn from `useCountrySignal` (a **client-side 30-day z-score** over `useWeeklyArchive`, bucketed H ≥ 1.5σ · E ≥ 0.5σ, forced quiet under 2 articles — coverage-volume anomaly, deliberately distinct from the backend `riskScore`/tier shown in the detail panel). A 4th **Economy** lens (disruption-exposure rings) was **removed 2026-07-29**: its `econByISO` lookup missed `nameToISO`'s lowercased keys, so it had never rendered a ring, and an always-empty exposure layer reads as \"no exposure anywhere\" ([[feedback_no_misinformation_fallback]]). Disruption data still reaches this page via the lede band and the detail panel's Economic Disruption section. Rendering is **imperative d3** — `drawMap()` wipes and re-appends the whole SVG on a 240ms-debounced effect, so live data reaches it through state-mirrored refs; geometry is `world-atlas@2/countries-110m` fetched from jsDelivr **at runtime**, not bundled |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. **Corrections ledger now also renders `FollowButton` on country-scope rows** (2026-07-08, `FOLLOW_SURFACING_PLAN.md`, `b455ee1`) — sibling of the name link; `FollowButton` was CountryPage-only until now. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). **07-09:** accepts `?stories=<id>[,…]` preselect (seeds once on topics-load; unknown ids dropped) — deep-linked from Home per-story \"Analyze →\", ThreadPage \"Analyze this arc\", `/economy`. **07-10:** output is ICD-203-structured (Key Judgments + yardstick + confidence axis + indicators table); the trailing `gp-struct` JSON block is extracted by `utils/analysisStruct.js` (validator/Markdown get stripped prose) and rendered as `atoms/AnalysisVisuals.jsx` **ScenarioBars/IndicatorMatrix/RippleTable** (struct null → prose-only honest fallback). **07-10 (later, `4193610`):** prompt fix raised DeepSeek's `gp-struct` emission 1/4→8/8 so the visuals render reliably; **Alibaba Qwen** added to the provider list with a workspace base-URL override for `sk-ws-` keys. |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n34 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter / **Alibaba Qwen**) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Qwen (added 2026-07-10)** — flagship `qwen3.7-max` (+`qwen3-max`/`qwen3.7-plus`), `enable_thinking:false` (same non-streaming reason as DeepSeek V4); its keyHint/comment flag that Alibaba **workspace-scoped `sk-ws-…` keys** 401 the default `dashscope-intl` host and need the WorkspaceId-specific `…maas.aliyuncs.com` host → handled by `allowBaseUrlOverride` + the optional \"Endpoint URL\" field in `ProviderModal` (persisted in `byok.js`, applied via `runChat({baseUrl})`→`baseUrl||provider.baseUrl`; every other provider unchanged when blank). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Professional structure (2026-07-10, `ANALYSIS_PRO_STRUCTURE_PLAN.md`):** prompts upgraded to ICD-203/desk-note packaging — Key Judgments box (BLUF, each judgment = yardstick probability term + separate justified confidence), the ICD-203 probability yardstick verbatim, an `| Indicator to watch | Confirms | Kills |` table on the scenario lens, and a `gp-struct` machine-readable block → ScenarioBars/IndicatorMatrix/RippleTable visuals (a chart can only show numbers the prose already states; every rule carries an omit-when-unsupported escape hatch — the 06-13 structure-pressure-induces-fabrication lesson). Server prompt (`newsAnalyze`) synced verbatim; guided maxTokens 1600→2400.\n- **Surfacing (2026-07-09, `ANALYZE_SURFACING_PLAN.md`):** was ONE nav link → now Home per-story \"Analyze →\" pill + hero line, ThreadPage \"Analyze this arc →\", `/economy` cross-link, all deep-linking via `?stories=` preselect.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — **only `newsPolarBilling` and `newsAnalyze` own CORS in code** (`corsHeaders` + an OPTIONS 204 handler); their Function-URL CORS config **must stay empty**, or AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser; bit us at the Polar checkout 2026-06-30). **Corrected 2026-07-25 (staleness audit):** `newsSavedItems` and `newsRecommend` do the **opposite** — their code emits **no** ACAO and they rely on a **populated** Function-URL CORS config. Do **not** clear their Function-URL CORS (that would remove all CORS and break them in-browser). Rule of thumb before touching any function's Function-URL CORS: grep the source for `Access-Control-Allow-Origin` first — code emits it ⇒ Function-URL CORS empty; code doesn't ⇒ Function-URL CORS populated.\n"}, "bd328d6700f2286c27714c4a56c3848ac568f861": {"t": 1785765779.741581, "n": 188297, "h": "b2f5d8b2c75682a5", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-08-01 (refresh: **WORKTREE CONSOLIDATION — `signal-api` MERGED, P6 PERK SURFACING SHIPPED, `/map` ECONOMY LENS REMOVED**. A survey of the 21 parallel worktrees found **19 finished** (fully merged or content-superseded) and only 2 carrying real work. (1) **`signal-api` merged to `main` (`59054ec`)** — the 4 prod-deployed-but-source-orphaned Lambdas (`newsSignals`/`newsImpactAudit`/`newsGdacsIngest`/`newsGdeltConflict`) now have source on `main`, closing the \"Prod infra NOT on `main`\" gap below; **merged ≠ reconciled — the deployed bytes drifted for ~5 weeks and are still the authority, so diff before changing any of the four.** Conflicts in `newsBreakingAlert` resolved as a union (`significance.js` holds BOTH the `RISK_CAP=50` rework AND the `axisForCategory` dominance fix; tests reconciled to the capped behaviour, 50/50). The V4 `thinking:{type:'disabled'}` guard was verified intact in `newsInvokeGemini` post-merge, alongside the branch's `captureIngestion` harness. (2) **P6 member-perk surfacing** cherry-picked + deployed (`80945bd`) — the load-bearing piece is a real bug: Home's trust strip counted corrections with `notes.length`, but `corrections_feed` caps that list at **5 for non-members**, so anonymous visitors were shown \"5 revised conclusions logged\" instead of the true total; it now reads the server's honest `total` (the *count* is public even though ledger *depth* is gated). Plus a weekly-email change-alert CTA and a Studio upsell that describes membership as allowance + history + alerts rather than compute-only. (3) **`/map`'s 4th \"Economy\" layer lens deleted** (`d14a2ff`) — its `econByISO` lookup missed `nameToISO`'s lowercased keys so it had **never rendered a ring**, and an always-empty exposure layer reads as \"no exposure anywhere\" ([[feedback_no_misinformation_fallback]]); the sibling paths (`todaySignal`, `realFlows`) lowercase correctly and were unaffected, and disruption data still reaches the page via the lede band + detail panel. A `quality/verify_pages.sh` guard that asserted the lens by grepping the bare string `\"economy\"` was passing **vacuously** off the panel's `tab:'economy'` deep-link — replaced with two specific guards (`daf2de9`). Cleanup: 20 worktrees + 22 branches pruned; only `main` + the `signal-api` worktree remain. Prior: **SILENT OUTPUT-TRUNCATION IN `newsSystemsAnalysis` — CEILING RAISED**. The 07-29 run went `7 generated, 3 failed` (`Unterminated string in JSON at position 10929`, Iran + Ukraine): `MAX_TOKENS` was **unset** → code default **3000**, and the graphs had outgrown it. Measured by replaying the real prompts at an 8000 ceiling — Iran **3214** tokens, Ukraine **3062**, US **3038** — i.e. the whole cohort sat in a 3038–3214 band *straddling* the limit, so which countries failed was luck (US passed that morning at 3038 and was already over). Fix = env **`MAX_TOKENS=6000`** (~87% headroom; `max_tokens` is a ceiling, not spend); re-run confirmed Iran + Ukraine now generate. **Silent class again:** caught per-country → exit 200 → CloudWatch `Errors` `0.0`, and neither `newsFreshnessMonitor` (topics only) nor `newsModelGuard` #30 (model ids only) covers it — same family as the 06-01 truncation stall. Also corrected: **`newsPostDevTo` #7 is the sole producer of the `/daily` Daily Brief** (a DeepSeek `callGrok` path entirely separate from its OpenRouter Dev.to article) — the doc had described only the Dev.to half, so nothing pointed a triager at this Lambda when `/daily` empties. Prior: **DEEPSEEK `deepseek-chat` RETIREMENT — FLEET MIGRATED TO V4**. `deepseek-chat` was retired by DeepSeek **2026-07-24**, silently breaking all 11 DeepSeek Lambdas for ~36h (most swallow the LLM error and exit 200, so CloudWatch showed 0 errors — only the freshness monitor's stale-content alert surfaced it). **Fixed 2026-07-26:** added `thinking: { type: 'disabled' }` to each function's DeepSeek request body (V4 defaults to thinking-mode → burns `max_tokens` on invisible `reasoning_content` → truncated/empty output — proven live: v4-flash without the patch clustered only 1 topic at `finish_reason=length`), then flipped `GROK_MODEL` per an intent-based split — **v4-pro** for the 3 open-analytical functions (`newsCountryIntelligence`, `newsSystemsAnalysis`, `newsEconomicImpact`), **v4-flash** for the mechanical/constrained/judge-protected/high-volume rest (`newsInvokeGemini`, `NewsProjectInvokeAgentLambda`, `newsDriftCorrector`, `newsPredictionResolver`, `newsWeeklyBrief`, `newsWeeklyMarkets`-producer, `newsPairIntelligence`, `newsImpactAudit`). Pipeline verified live end-to-end (13 topics → 39 AI items → swap). The `thinking` patch is now **ported into the repo `src/index.js` of all 10 on-main functions** (2026-07-27) so a future zip-from-repo deploy won't regress it. ✅ PPA/property project (same account) also migrated — `PPAcomputeWardProfiles-dev` + `PPAfetchMarketNews-dev` both verified on `LLM_MODEL=deepseek-v4-flash` (2026-07-27). Prior: **AGENT-REVIEW STALENESS AUDIT** — a 5-auditor parallel sweep verified the whole doc against live AWS (`AGENT_REVIEW_METHOD.md`). Fixes landed: (1) **`newsBreakingAlert` #21 is DEPLOYED + scheduled + `DRY_RUN=false`**, not \"BUILT — NOT DEPLOYED\" (still human-confirm-before-send); (2) **4 of 7 EventBridge *Scheduler* entries run `Asia/Tokyo`, not UTC** — `InvokeLinkedIn`/`LinkedinThreadsDaily`/`Fact` corrected (`InvokeDev` was already right); the \"All times UTC\" header was wrong; (3) **`newsPairIntelligence` #8 is on the ENABLED weekly rule `TriggerPairIntelligenceWeekly`**, not manual-only; (4) **Common Mistakes #7 CORS list corrected** — only `newsPolarBilling`+`newsAnalyze` own CORS in code (Function-URL CORS empty); `newsSavedItems`+`newsRecommend` do the opposite (populated Function-URL CORS, no ACAO in code) — clearing theirs would break them; (5) **a Signal-API + impact-first ingest subsystem** (`newsSignals`/`newsImpactAudit`/`newsGdacsIngest`/`newsGdeltConflict` + 6 DDB tables) is live in prod from the unmerged `signal-api` branch — now documented as \"Prod infra NOT on `main`\"; (6) stale counts: proxy actions ~27→~33, components ~64→67, atoms 17→19, hooks 32→34; `newsPostLinkedin` env `SOCIAL_POSTS_TABLE`→deployed `LINKEDIN_POSTS_TABLE`. Backend #10–20 + frontend routes/components/hooks mappings verified CLEAN. ~~Note to reconcile: CLAUDE.md's Dual-CORS bullet + memory `feedback_lambda_function_url_cors` still carry the old 4-function CORS list~~ — **RESOLVED (verified 2026-07-29): both already carry the corrected list** (only `newsPolarBilling`+`newsAnalyze` own CORS in code / empty Function-URL CORS; `newsSavedItems`+`newsRecommend` the opposite), re-confirmed against live `get-function-url-config`. Prior: **ANALYSIS STUDIO — VISUAL-BLOCK RELIABILITY FIX + QWEN PROVIDER** [[project_analysis_studio]]. 07-10 [`4193610`, `QWEN_AND_VISUAL_BLOCK_PLAN.md`]: a **qwen3.7-max vs deepseek-v4-pro bake-off** (same stories/lenses, shipped pipeline) exposed the real \"where's the graph?\" cause — DeepSeek (the member-path model) emitted the `gp-struct` block in only **1/4** runs (writes the scenario prose but skips the machine-readable block), so the 07-10 ScenarioBars/IndicatorMatrix/RippleTable rarely rendered for members; Qwen emitted 4/4. **Fix (prompt-only, `analysisPrompt.js`):** embedded a fully-filled JSON template in SYSTEM_PROMPT + a mandatory tail reminder inside each lens task (where the model finishes) + removed the easy \"omit the whole block\" exit for scenario/economic lenses → valid-block emission **1/4 → 8/8** live (`scratchpad/emit-test.mjs`); honesty intact — `validateStruct` still strips any figure not literally in the prose, so all 8 validated with zero fabrication. `newsAnalyze` Lambda re-synced via the **patched-deployed-zip** method (Node made deployed SYSTEM_PROMPT byte-equal to the frontend, `FE===BE` 5278 chars; deployed bytes verified: template present, omit-exit gone, `creditBalance`=0, `MAX_TOKENS`=2400; lens-task tail reminders ride in via the frontend-built `body.user`). **Bake-off verdict:** DeepSeek stays the member-path default (tighter/MECE probability bands, deeper prose, always states confidence); **Qwen3.7-max added as a faster (~36%) BYOK option** — but Alibaba's international keys are **WORKSPACE-scoped (`sk-ws-…`)** and 401 the generic `dashscope-intl` host, needing `https://<WorkspaceId>.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1` (Tokyo; verified with `ws-gl08109galyxzzh3`), so a per-provider **base-URL override** was added (`allowBaseUrlOverride` on qwen; `runChat`/`runOpenAICompat` take optional `baseUrl`→`baseUrl||provider.baseUrl`; ProviderModal optional \"Endpoint URL\" field; `byok.js` persists it back-compat) — live-tested end-to-end through real `runChat` (override→200, default→401). Separately (same day, prod-config only): the member path itself was switched to **`deepseek-v4-pro`** (`ANALYZE_DAILY_CAP` 50→100, `Timeout` 30s→120s), which required patching `runDeepSeek` to send `thinking:{type:'disabled'}` — v4-pro's default thinking mode otherwise burns the whole `MAX_TOKENS` budget and returns empty output — and surfaced a lesson that a timed-out synchronous Function-URL Lambda returns no-ACAO, which looks like CORS but isn't. Prior: **ANALYSIS STUDIO SURFACED + PROFESSIONAL STRUCTURE**. 07-09 [`8cf2b29`, `ANALYZE_SURFACING_PLAN.md`]: `/analyze` was discoverable from ONE nav link (~2 runs/30d = discovery gap, not quality — engine live-tested 0/3 fabrication, and a manual web truth-check found every auditor flag was a FALSE POSITIVE vs ground truth); surfaced via a per-story **\"Analyze →\"** pill on Home (4th control beside Summary/Predict/Trace), a Home hero value-prop line, ThreadPage **\"Analyze this arc →\"** (deep-links `thread.entries[0].topicId`), an `/economy` cross-link, and a new **`?stories=<id>[,…]` preselect** in `AnalysisStudio.jsx` (seeds once on topics-load, unknown ids silently dropped). 07-10 [`c429931`, `ANALYSIS_PRO_STRUCTURE_PLAN.md`]: output upgraded to ICD-203/desk-note packaging — **Key Judgments box** (BLUF bullets), the **ICD-203 probability yardstick** verbatim (\"very likely (80–95%)\"), **confidence as a separate justified axis**, a consolidated **`| Indicator to watch | Confirms | Kills |` table** on the scenario lens, and a fenced **`gp-struct` JSON block** (a machine-readable INDEX of the prose — never new content) parsed by new `utils/analysisStruct.js` (truncation-safe; drops scenarios whose probability digits aren't literally in the prose) → rendered by `atoms/AnalysisVisuals.jsx` (**ScenarioBars/IndicatorMatrix/RippleTable**, pure CSS). Validator + Markdown always receive the STRIPPED prose. Review caught 4 pre-deploy bugs: `newsAnalyze`'s server-pinned prompt had **DRIFTED** (missing all 06-13 upgrades — members got a weaker analyst than BYOK; now synced verbatim), gp-struct truncation at 1600 tokens (guided maxTokens→2400, Studio+eval+Lambda env), and two validator false-positives from the new structure (comparison-operator thresholds `>50%`; scenario-heading ranges — estimative look-behind 32→48; goldens added). **⚠️ prod `newsAnalyze` deploy = PROMPT-PATCH of the downloaded DEPLOYED (pre-credits 06-16) zip — the repo file carries the PARKED credits code and must NOT reach prod until `PROD_CREDITS_NEXT_STEPS.md` runs.** Prior: **SECOND PREDICTION RESOLUTION RUN + DRIFT-EMAIL PER-AXIS VIEW + FOLLOWBUTTON ON THE LEDGER** [[project_prediction_methodology_v1]] [[project_scoring_model_v2]]. The 07-08 agent-run resolution sweep scored **all 21 due triggers** [incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers from the 07-07 run, cleared on recheck]. **`/track-record` now shows resolved 35 (was 15), fired 9 (was 4), Brier 0.157 (was 0.084)**; calibration buckets `0-20%`→stated ~17%/fired 9% (n11) · `20-40%`→stated ~20%/fired 9% (n11) · `60-80%`→stated ~65%/fired 54% (n13). Brier rose on purpose: run-1 was flattered by easy low-probability tails; run-2 added real high-stakes calls with genuine misses — the honest direction. The 65% bucket runs ~10pts hot (54% actual) but the curve is monotonic (9%→9%→54%). Cadence confirmed weekly agent-in-session. Prior (07-08, both already on main): **drift-alert email → per-axis view** [`0ec494f`, `SCORING_MODEL_V2_PLAN.md` §12] — `newsDriftCorrector` now embeds `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel` onto each new `DRIFT#`/`DRIFTLOG#` country note; `renderDriftEmail.js` renders a degradation ladder (currentDimensions → Level B 4-axis scorecard `HIGH · Humanitarian 82` + `⚠ N/4 elevated` → changeDimensions → Level A per-axis pills → legacy scalar); not retroactive. **FollowButton surfaced on the `/track-record` corrections ledger** [`b455ee1`, `FOLLOW_SURFACING_PLAN.md`] — the member country-follow control (previously CountryPage-only) now also renders as a sibling of the name link on each **country-scope** ledger row, the highest-intent \"get the next change emailed\" moment; management still lives at `/account?tab=notifications`. Deferred surfaces: CountryWhatChanged band (redundant), ThreadPage (multi-country ambiguity), countries-index (nesting/grid). Prior: **FIRST PREDICTION RESOLUTION RUN DONE + G6 SPORTS-SCOPE GATE LIVE** [[project_prediction_methodology_v1]]. The accountability record is no longer empty: the first agent-run resolution loop (`predictions/V1_RESOLUTION_RUNBOOK.md`) scored **16 of 21 due triggers** — 3 parallel blind web-grounded verifiers → 2nd independent blind pass on every `fired` → operator ~10% spot-check → dry-run → `resolve-v1-write.js --commit`. **`/track-record` now shows Brier 0.084**, resolved 15 (fired 4 · not_fired 11) + 1 unclear, calibration buckets 0-20%→0% (n6) · 20-40%→0% (n4) · 60-80%→80% (n5) [caveat: n tiny, 11/15 were low-p tails correctly not firing = easy points]; 5 triggers deferred (window still open on run day — Le Pen ruling ×3, INDOPACOM, Andersen). Deadlines arrived earlier than the ~07-11 estimate (short-horizon triggers). **New capture gate G6** [`NewsProjectInvokeAgentLambda/src/lib.js`, deployed to `-dev` + deployed-bytes verified; source `91e069e` + deploy `73e490d`]: drops **pure sporting-result** triggers — a match/tournament outcome or on-field participation carries no geopolitical/economic/institutional signal (the remit). Rule = drop only when BOTH a sporting-competition **context** regex AND a **result/participation** regex match, so sport-**adjacent** governance triggers survive (no result verb: \"Belgium files a FIFA protest\", \"CAS grants an interim measure\" pass). **Corpus-validated: across all 1038 live v1 triggers G6 drops exactly the 2 Trump–FIFA/Balogun box-score triggers, 0 false positives**; `node --test` 21/21. NOT retroactive (already-published sport triggers stay scored); scope decision was fix-forward at the trigger layer, NOT an ingest ban. Methodology disclosure live on `/track-record`. Cadence = weekly agent-in-session. Prior: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- **DeepSeek V4 (post-2026-07-26 migration — `deepseek-chat` is retired; all send `thinking:{type:'disabled'}`):**\n - **`deepseek-v4-pro`** (deeper reasoning; the open-analytical reads): `newsCountryIntelligence`, `newsSystemsAnalysis`, `newsEconomicImpact`\n - **`deepseek-v4-flash`** ($0.14/M in · $0.28/M out; mechanical/constrained/judge-protected/high-volume): `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsDriftCorrector`, `newsPredictionResolver`, `newsWeeklyBrief`, `newsWeeklyMarkets` (producer call), `newsPairIntelligence`, `newsImpactAudit`\n - `newsPostDevTo` uses **OpenRouter** (`deepseek/deepseek-v4-flash:free`), not the direct DeepSeek API — see #7\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek v4-flash** (migrated 2026-07-26 from the retired `deepseek-chat`; `api.deepseek.com`, `thinking:disabled`). On the weekly rule `TriggerPairIntelligenceWeekly` + ad-hoc manual invokes.\n- `newsAnalyze` → **DeepSeek v4-pro** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=100`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). **DEPLOYED + scheduled** (`TriggerBreakingAlert` 4-hourly, `DRY_RUN=false`; proposes only, human confirms before send) — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~33 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek v4-flash** (migrated 2026-07-26 from the retired `deepseek-chat`; `thinking:disabled`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-v4-flash` in production (was `deepseek-chat` until the 2026-07-26 retirement migration). Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check · **G6 capture-scope — drops pure sporting results, added 2026-07-07**) before it can enter the immutable log — a malformed or out-of-scope trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. G6 fires only when a trigger has BOTH a sporting-competition context AND a result/participation verb (so sport-adjacent governance triggers survive); corpus-validated 2/1038, 0 false positives. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (21/21, uses the real 2026-07-04-pilot defect triggers + the 2026-07-07 Balogun box-score triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-v4-flash` in production (was `deepseek-chat` until the 2026-07-26 retirement migration; `thinking:disabled` in the request body). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. **Two agent-run resolution sweeps done (07-07, 07-08) → live: resolved 35, fired 9, Brier 0.157, calibration buckets populated** (was honest-empty before 07-07) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)`, **timezone `Asia/Tokyo`** (verified 2026-07-25 — the scheduler is NOT in UTC, same as `newsPostDevTo` #7). Fires at :20 past JST hours 0/3/6/… = **15/18/21/00/… UTC**.\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `LINKEDIN_POSTS_TABLE` (=`NewsProject-linkedin-posts`; the deployed var is **not** `SOCIAL_POSTS_TABLE` — code reads `SOCIAL_POSTS_TABLE || LINKEDIN_POSTS_TABLE` so the fallback wins), `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to) — **and, despite the name, is also the sole producer of the Daily Intelligence Brief behind `/daily`.**\n\n> ⚠️ **Misleading name — two independent jobs on two different providers** (corrected 2026-07-29; the doc previously described only the Dev.to half). `/daily` has no other producer, so a failure here silently empties the Daily Brief page while the Dev.to post is unaffected (and vice versa). When triaging `/daily`, look at *this* Lambda, not a \"daily brief\" function — there isn't one.\n> - **`generateAndStoreDailyBrief` → `callGrok`** uses **DeepSeek** via `GROK_MODEL`/`GROK_API_URL`/`XAI_API_KEY` and writes `DAILY_BRIEF#{dateKey}` to `SUMMARIZE_PREDICT_TABLE`. Migrated to `deepseek-v4-flash` + `thinking:{type:'disabled'}` on 2026-07-27 (main `c232624`) — it was the last straggler on the retired `deepseek-chat` alias.\n> - **The Dev.to article** uses **OpenRouter** via `AI_MODEL` — a completely separate call path and key.\n> - The stored brief carries a `model` field sourced from `GROK_MODEL`, so briefs written before 07-27 are labelled `deepseek-chat`.\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. **Builds + stores the Daily Brief** (`DeepSeek`, 50 archive entries + thread/country enrichment) → `DAILY_BRIEF#{dateKey}` / `DAILY_BRIEF`, 90-day TTL. Served by the `daily_brief` action → `/daily`\n3. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n4. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n5. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `XAI_API_KEY` / `GROK_MODEL` (=`deepseek-v4-flash`) / `GROK_API_URL` (the **daily-brief** path — legacy names holding DeepSeek values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPairIntelligenceWeekly` — **ENABLED** — `cron(0 8 ? * MON *)` (Mondays 08:00 UTC; **corrected 2026-07-25 — was documented as manual-only**). Also invocable manually with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for the default 10 pairs.\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** the source default still reads Grok, but the **deployed** function is on DeepSeek (`GROK_MODEL=deepseek-v4-flash` since the 2026-07-26 retirement migration — was `deepseek-chat`; `GROK_API_URL=https://api.deepseek.com/chat/completions`; request body sends `thinking:disabled`). Runs weekly via `TriggerPairIntelligenceWeekly` (see trigger above) plus ad-hoc manual invokes.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`, **`MAX_TOKENS` (=`6000`; code default `3000` — see below)**\n\n> ⚠️ **Output-truncation ceiling (raised 2026-07-29).** `MAX_TOKENS` was **unset**, so this ran on the code default **3000** — and the graphs outgrew it. On 07-29 the run went `7 generated, 3 failed` with `Failed to parse Grok response: Unterminated string in JSON at position 10929` for Iran/Ukraine. **Measured** (replaying the real prompts at an 8000 ceiling): Iran needs **3214** completion tokens, Ukraine **3062**, United States **3038** — the whole 12-country cohort had drifted into a 3038–3214 band straddling the 3000 limit, so which countries failed was luck, not content (US passed that day at 3038 and was already over). Truncation arithmetic confirms it exactly: 12032 full chars − 10929 clipped = 1103 chars ≈ the 214-token overshoot. Fix = env `MAX_TOKENS=6000` (~87% headroom over the measured peak); `max_tokens` is a **ceiling, not spend**, so cost is unchanged. **Silent class:** failures are caught per-country and the function exits 200, so CloudWatch `Errors` stayed `0.0` — and neither `newsFreshnessMonitor` (watches `topics` only) nor `newsModelGuard` (#30, model ids only) covers this. Same family as the 06-01 `newsInvokeGemini` JSON-truncation stall; if graphs later exceed ~20 threads, port that function's `salvageTruncatedTopics()` pattern here (this one's `JSON.parse` has no salvage branch).\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` — DEPLOYED + SCHEDULED (proposal mode; `DRY_RUN=false`)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerBreakingAlert` — **ENABLED** — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis).\n\n> ⚠️ **Doc corrected 2026-07-25 (agent-review staleness audit):** this function was long documented as \"BUILT — NOT DEPLOYED (dry-run)\". Live AWS shows it is **deployed** (Timeout 120s / 256MB), driven by the **ENABLED** rule above, wired to the `GlobalPerspectiveBreakingAlerts` table, with **`DRY_RUN=false`**. What is still true is the **human-in-the-loop / never-auto-sends** design: even at `DRY_RUN=false` the code only writes a `status:'proposed'` row and logs \"awaiting human confirmation before send\" — the actual email send lives in `newsEmailSender` breaking mode (#29), gated on a human `status:'confirmed'`. So \"proposes automatically, sends only after human confirm,\" **not** \"dry-run/un-deployed.\"\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` runs live on its 4-hourly schedule writing structured `status:'proposed'` records; legacy confirmed records render via the saved-email-text fallback in `get_alert`.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (deployed, verified 2026-07-25):** `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE` (=`SummarizeAndPredict`), `BREAKING_ALERTS_TABLE` (=`GlobalPerspectiveBreakingAlerts`), `RESEND_API_KEY` (set), `SITE_URL`, `DRY_RUN` (=`false`), plus `ALERT_EMAIL_FROM` (=`onboarding@resend.dev`) and `ALERT_EMAIL_TO` (operator address). `SIGNIFICANCE_THRESHOLD`/`DEDUPE_DAYS` are **not** set → code defaults only. No Function URL (this is a scheduled Lambda, not URL-invoked).\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars (deployed, verified 2026-07-25):** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `USERS_DDB_TABLE` (=`GlobalPerspectiveUserTable`, added for the follow-country tier check), `FIREBASE_PROJECT_ID`. (`BREAKING_ALERTS_TABLE` and `SITE_URL` are **not** set in the deployed env — code falls back to literals.) **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered. Country-note fields: `changeScore`/`changeLevel`/`changeDimensions{axis:{from,to,delta}}` (what moved) + `priorHeadline`/`currentHeadline` + `triggerEvent`/`whyChanged`/`noSingleDriver`, **and (scoring-v2 Phase D-email, 2026-07-08) the current standing `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel`** for the email's Level-B scorecard (the vector was already loaded for `changeDimensions`; not backfilled — new notes only).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**⚠️ DEPLOYED ≠ REPO (2026-07-10):** prod still runs the **pre-credits 06-16 code** (gate = `ANALYZE_DAILY_CAP=50`) with the **P1 professional-structure SYSTEM_PROMPT patched in** (2026-07-10: downloaded the deployed zip, patched ONLY the prompt block, redeployed; `MAX_TOKENS` env 1600→2400 for the longer structured output). The server-pinned prompt is now mirrored **verbatim** from the frontend's `utils/analysisPrompt.js` — it had silently drifted (missing all 06-13 upgrades, so member runs got a weaker analyst than BYOK). **Re-synced again 2026-07-10 (`4193610`)** with the **gp-struct visual-block reliability fix** (embedded JSON template + emission mandate — raised valid-block emission on the DeepSeek member path from 1/4 to 8/8, so the ScenarioBars/etc. now actually render for members); the lens-task tail reminders reach the model via the frontend-built `body.user` (the Lambda only holds `SYSTEM_PROMPT`), so syncing that array was sufficient. Same patched-zip method: `FE===BE` verified byte-for-byte (5278 chars). **Never zip the repo `src/index.js` to prod until `PROD_CREDITS_NEXT_STEPS.md` runs** — it carries the parked credits code, and deploying it without the env-ordering steps can mis-grant orders. **Model switch + timeout fix (2026-07-10, prod-config only):** the member-path model was switched `deepseek-chat` (V4 Flash) → **`deepseek-v4-pro`** (env `GROK_MODEL`, the bake-off's stronger analyst), with a required code patch (same patched-deployed-zip method) sending `thinking:{type:'disabled'}` in `runDeepSeek` — v4-pro defaults to thinking mode, which without this burns the whole `MAX_TOKENS` budget on `reasoning_content` and returns **empty output** (`len=0, finish_reason=length`, proven live before the fix; code patch deployed before the env flip so there was no broken window). `ANALYZE_DAILY_CAP` raised 50→100 and `Timeout` raised 30s→120s (v4-pro takes ~40–60s; the old 30s timeout surfaced in-browser as a fake-CORS **\"Failed to fetch\" / \"No 'Access-Control-Allow-Origin' header\"** error — the Function-URL CORS config was correctly empty throughout, so this was NOT the dual-CORS trap: on a synchronous Function-URL Lambda, a timed-out invocation returns a bare platform error with no CORS headers, which reads as CORS but is actually a timeout).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live). **Per-axis view — scoring-v2 Phase D-email (2026-07-08, `SCORING_MODEL_V2_PLAN.md` §12):** `renderDriftEmail` renders a degradation ladder — `note.currentDimensions` → **Level B** compact scorecard (`HIGH · Humanitarian 82` + 4-axis row + `⚠ N/4 elevated`); else `changeDimensions` → **Level A** per-axis change pills; else the legacy scalar. No frontend-util import (reads the note's derived headline fields; breadth flag inline). Not retroactive — Level B only for notes written after the corrector deploy.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n### Signal-API + impact-first ingest — source MERGED to `main` 2026-08-01\n\nFour Lambdas **deployed and ENABLED in prod** whose source lived only on the unmerged `signal-api` branch until **2026-08-01**, when that branch was merged (`59054ec`) — closing the \"Prod infra NOT on `main`\" gap the 2026-07-25 staleness audit flagged. They pair with the 6 DDB tables below. See [[project_signal_api_deployed]], [[project_impact_first_redesign]].\n\n> ⚠️ **Merged ≠ reconciled.** The merge landed the branch's *source*; it does **not** prove the source matches the **deployed bytes**, which drifted independently for ~5 weeks while only the deployed copy was live. Before changing any of these four, still diff against live AWS (`aws lambda get-function --query 'Code.Location'`). Known drift class: the merge brought `newsInvokeGemini`'s `captureIngestion` harness onto `main` alongside `main`'s own V4 `thinking:{type:'disabled'}` patch (both verified present post-merge, different regions of the file) — but whether prod's deployed `newsInvokeGemini` carries the harness is unverified. Two conflicts in `newsBreakingAlert` were resolved as a union: `significance.js` genuinely holds **both** the branch's `RISK_CAP=50` / risk-weight-2.0→1.0 rework **and** `main`'s `axisForCategory`/`regionRiskScore` category-axis dominance fix; the test suite was reconciled to the capped behaviour (50/50 pass).\n\n| Function | Trigger (ENABLED) | Config | Role (from deployed env + tables) |\n|---|---|---|---|\n| **`newsSignals`** | `TriggerSignalsBuild` `cron(0 10 * * ? *)` + a public **Function URL** (AuthType NONE, key-gated in code) | 300s / 512MB | Signal-API v1 builder — writes `GlobalPerspectiveSignals` (120d TTL) from `PredictionLog`/`SummarizeAndPredict`/`BreakingAlerts`; `API_KEYS_TABLE=GlobalPerspectiveApiKeys`, `FREE_DELAY_HOURS=24` (paid-tier freshness gate). |\n| **`newsImpactAudit`** | `TriggerImpactAudit` `cron(0 9 * * ? *)` | 120s / 256MB | Coverage dead-man's-switch — DeepSeek audit of whether high-impact events were MISSED; `IMPACT_AUDIT_TABLE`/`INGEST_CAPTURE_TABLE`, SNS→`GlobalPerspectiveAlerts` when misses ≥ `MISS_ALERT_THRESHOLD=2`. |\n| **`newsGdacsIngest`** | `TriggerGdacsIngest` `cron(0 */6 * * ? *)` | 120s / 256MB | GDACS disaster-alert ingest feed → `GlobalPerspectiveGdacsEvents`. |\n| **`newsGdeltConflict`** | `TriggerGdeltConflict` `cron(0 */6 * * ? *)` | 120s / 512MB | GDELT conflict-event ingest feed → `GlobalPerspectiveGdeltConflict`. |\n\n---\n\n### 30. `newsModelGuard` — model-retirement dead-man's-switch (LIVE 2026-07-28)\n**Path:** `amplify/backend/function/newsModelGuard/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerModelGuard` — **ENABLED** — `cron(0 12 * * ? *)` (daily 12:00 UTC). **No LLM** (one cheap authenticated `GET /models`).\n\nThe passive guard against a repeat of the **2026-07-24 `deepseek-chat` retirement** (which became a 36h silent outage because nothing was watching the deadline — see `BACKEND_DEEPSEEK_V4_MIGRATION_PLAN.md`). Daily it `ListFunctions` across the whole account, reads each function's env for model-ID vars (`GROK_MODEL`/`LLM_MODEL`/`MODEL`/`AUDIT_MODEL`/`JUDGE_MODEL`/`PPLX_MODEL`/`AI_MODEL`), and flags any **direct DeepSeek id** (`/^deepseek-/`; the OpenRouter `deepseek/…` namespaced form is skipped) that is either a **deprecated alias** (`deepseek-chat`/`deepseek-reasoner`) or **absent from DeepSeek's live `/models` list** (already retired/invalid). Findings → SNS `GlobalPerspectiveAlerts` with the proven fix recipe; **honest-empty** (silent when all clear). Skips `*-sandbox` functions + `IGNORE_FUNCTIONS` to avoid nagging on throwaway test envs. **Known gap:** env-scan only — a function hitting the retired model via a hardcoded code default (`process.env.X || 'deepseek-chat'`) is invisible here; those were separately hardened to `deepseek-v4-flash` defaults 2026-07-28.\n\n**Key env vars:** `DEEPSEEK_API_KEY` (copied from the fleet — auths the `/models` call), `DEEPSEEK_MODELS_URL` (=`https://api.deepseek.com/models`), `SNS_TOPIC_ARN` (=`GlobalPerspectiveAlerts`), optional `MODEL_VARS`/`DEPRECATED_ALIASES`/`IGNORE_FUNCTIONS`. **Role:** `newsModelGuard-role` (basic-exec logs + `lambda:ListFunctions`/`GetFunctionConfiguration` account-wide read + `sns:Publish` to the alerts topic only).\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch); **`newsModelGuard` (#30)** alerts when any Lambda is left on a deprecated/retired DeepSeek model (the model-retirement dead-man's-switch, born from the 2026-07-24 `deepseek-chat` outage). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d, in-scope — G6 drops pure sporting results from 2026-07-07) and gated-out triggers are recorded in `capture.dropped` with their `gate`; a scenario with no surviving dated trigger carries `scoreable:false`. `finalVerdict`/`confirmedBy:'agent-verified'` written by the Phase-2 agent loop (first run 2026-07-07). Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Populated live by the scheduled detector (`newsBreakingAlert` runs 4-hourly, `DRY_RUN=false`) — see [Lambda #21](#21-newsbreakingalert--deployed--scheduled-proposal-mode-dry_runfalse) + `BREAKING_ALERTS_PLAN.md`.\n\n### Signal-API + ingest tables (live in prod; producer source merged to `main` 2026-08-01)\nThese exist in prod DynamoDB; their producers' source lived on the unmerged `signal-api` branch until the 2026-08-01 merge (see \"Signal-API + impact-first ingest\" under the Lambda inventory — note the merged-≠-reconciled caveat). Key schemas confirmed via `describe-table` 2026-07-25:\n\n| Table | Key | Written by | Contents |\n|-------|-----|-----------|----------|\n| `GlobalPerspectiveSignals` | `signal_id` | newsSignals | Signal-API v1 records (120d TTL) |\n| `GlobalPerspectiveApiKeys` | `keyHash` | mint-key.mjs / newsSignals | Signal-API access keys |\n| `GlobalPerspectiveGdacsEvents` | (see describe-table) | newsGdacsIngest | GDACS disaster-alert ingest |\n| `GlobalPerspectiveGdeltConflict` | (see describe-table) | newsGdeltConflict | GDELT conflict-event ingest |\n| `GlobalPerspectiveImpactAudit` | (see describe-table) | newsImpactAudit | Coverage-audit results |\n| `GlobalPerspectiveIngestCapture` | (see describe-table) | ingest feeds | Raw high-impact event capture (audited by newsImpactAudit) |\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G6** (`src/lib.js`; G6 = sporting-result scope, added 2026-07-07) → structured, forward-dated, in-scope, `methodologyVersion:1` triggers; malformed/out-of-scope ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4, runbook `predictions/V1_RESOLUTION_RUNBOOK.md`): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface. **Two runs done (weekly cadence):** 07-07 scored 16 of 21 due triggers (5 deferred, window still open on run day); **07-08 scored all 21 due triggers** (incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers, cleared on recheck). Same-day-deadline learning: verifiers need a \"defer\" option when the event window hasn't closed (not yet folded into the runbook doc). Live totals: resolved 35, fired 9, Brier 0.157 (up from 0.084 after run 1 — run 2 added real high-stakes calls with genuine misses, the honest direction; the 60-80% bucket runs ~10pts hot but the curve stays monotonic).\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n5. **Feedback (Phase 5, DESIGNED 2026-07-08 — data-gated, NOT built)** — `CALIBRATION_DIGEST_PLAN.md`: a weekly `newsCalibrationDigest` Lambda will compact the resolved-trigger trace into one ~200-token `CALIBRATION#DIGEST` row (probability-bucket calibration + per-reference-class fire rates + deterministic bias lines); generation GetItems it and injects a `calibrationBlock` into `buildPredictionPrompt`, stamping `methodologyVersion:2` (the flat-65% v1 era stays as control group). Build gates: CP-1 manual measure at ~50 resolved; CP-2 infra at ~150 resolved. Honesty rule: feedback changes what the model says NEXT, never remaps stored/displayed probabilities. Digest rows must NOT carry `methodologyVersion` (the extract script and `prediction_track_record` filter on it).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-07-25)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. **EventBridge Rules run in UTC; several EventBridge *Scheduler* entries run in `Asia/Tokyo` — do NOT assume UTC for the Scheduler table** (the \"All times UTC\" claim was wrong pre-2026-07-25: 4 of 7 schedulers are JST — see the timezone column below).\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `TriggerModelGuard` | `cron(0 12 * * ? *)` | newsModelGuard (#30 — daily model-retirement guard; SNS-alerts on deprecated/retired DeepSeek model) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n| `TriggerPairIntelligenceWeekly` | `cron(0 8 ? * MON *)` | newsPairIntelligence (#8 — Mondays 08:00 UTC; **NOT manual-only**, corrected 2026-07-25) |\n| `TriggerBreakingAlert` | `cron(15 */4 * * ? *)` | newsBreakingAlert (#21 — 4-hourly, ENABLED, `DRY_RUN=false`) |\n| `TriggerDriftCorrector` | `cron(20 7 * * ? *)` | newsDriftCorrector (#26 — 07:20 UTC daily) |\n| `TriggerWeeklyEmailSend` | `cron(0 14 ? * SUN *)` | newsEmailSender (#29 — weekly digest, ENABLED) |\n| `TriggerBreakingEmailSend` | `rate(15 minutes)` | newsEmailSender (#29 — breaking sends, ENABLED) |\n| `TriggerDriftEmailSend` | `cron(40 7 * * ? *)` | newsEmailSender (#29 — drift alerts, **DISABLED** by design) |\n| `TriggerFreshnessMonitor` | `cron(30 0/2 * * ? *)` | newsFreshnessMonitor (#18) |\n| `TriggerErrorDigest` | `cron(15 0/6 * * ? *)` | newsErrorDigest (#19) |\n| `TriggerSignalsBuild` | `cron(0 10 * * ? *)` | newsSignals (Signal-API — deployed to prod; source merged to `main` 2026-08-01) |\n| `TriggerImpactAudit` | `cron(0 9 * * ? *)` | newsImpactAudit (impact-first — source merged to `main` 2026-08-01) |\n| `TriggerGdacsIngest` | `cron(0 */6 * * ? *)` | newsGdacsIngest (GDACS ingest — source merged to `main` 2026-08-01) |\n| `TriggerGdeltConflict` | `cron(0 */6 * * ? *)` | newsGdeltConflict (GDELT ingest — source merged to `main` 2026-08-01) |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` **TZ Asia/Tokyo** | newsPostLinkedin (:20 past JST hours 0/3/6/… = **15/18/21/00/… UTC** — NOT UTC, corrected 2026-07-25) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` **TZ Asia/Tokyo** | linkedInAutoPost (07:30 / 19:30 **JST** = **22:30 / 10:30 UTC** — corrected 2026-07-25) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` **TZ Asia/Tokyo** | newsCountryFactsUpdater (05:00 **JST** = **20:00 UTC** prior day — corrected 2026-07-25) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual / user-triggered only)\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST / Function URL\n- (`newsPairIntelligence` was listed here as \"manual only\" — **corrected 2026-07-25**: it is now on the ENABLED weekly rule `TriggerPairIntelligenceWeekly`, Mondays 08:00 UTC.)\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, **deployed + scheduled**), newsEmailSender (#29) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~67 component files (in `src/components/` + 19 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then **three** stacked layer lenses: **Today's pulse** (24h fresh-news rings), **Connections** (bilateral arcs from `usePairAnalyses`), **Editorial** (top-5-by-signal numbered pins). Base country fills + two-tier signal markers are always drawn from `useCountrySignal` (a **client-side 30-day z-score** over `useWeeklyArchive`, bucketed H ≥ 1.5σ · E ≥ 0.5σ, forced quiet under 2 articles — coverage-volume anomaly, deliberately distinct from the backend `riskScore`/tier shown in the detail panel). A 4th **Economy** lens (disruption-exposure rings) was **removed 2026-07-29**: its `econByISO` lookup missed `nameToISO`'s lowercased keys, so it had never rendered a ring, and an always-empty exposure layer reads as \"no exposure anywhere\" ([[feedback_no_misinformation_fallback]]). Disruption data still reaches this page via the lede band and the detail panel's Economic Disruption section. Rendering is **imperative d3** — `drawMap()` wipes and re-appends the whole SVG on a 240ms-debounced effect, so live data reaches it through state-mirrored refs; geometry is `world-atlas@2/countries-110m` fetched from jsDelivr **at runtime**, not bundled |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. **Corrections ledger now also renders `FollowButton` on country-scope rows** (2026-07-08, `FOLLOW_SURFACING_PLAN.md`, `b455ee1`) — sibling of the name link; `FollowButton` was CountryPage-only until now. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). **07-09:** accepts `?stories=<id>[,…]` preselect (seeds once on topics-load; unknown ids dropped) — deep-linked from Home per-story \"Analyze →\", ThreadPage \"Analyze this arc\", `/economy`. **07-10:** output is ICD-203-structured (Key Judgments + yardstick + confidence axis + indicators table); the trailing `gp-struct` JSON block is extracted by `utils/analysisStruct.js` (validator/Markdown get stripped prose) and rendered as `atoms/AnalysisVisuals.jsx` **ScenarioBars/IndicatorMatrix/RippleTable** (struct null → prose-only honest fallback). **07-10 (later, `4193610`):** prompt fix raised DeepSeek's `gp-struct` emission 1/4→8/8 so the visuals render reliably; **Alibaba Qwen** added to the provider list with a workspace base-URL override for `sk-ws-` keys. |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n34 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter / **Alibaba Qwen**) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Qwen (added 2026-07-10)** — flagship `qwen3.7-max` (+`qwen3-max`/`qwen3.7-plus`), `enable_thinking:false` (same non-streaming reason as DeepSeek V4); its keyHint/comment flag that Alibaba **workspace-scoped `sk-ws-…` keys** 401 the default `dashscope-intl` host and need the WorkspaceId-specific `…maas.aliyuncs.com` host → handled by `allowBaseUrlOverride` + the optional \"Endpoint URL\" field in `ProviderModal` (persisted in `byok.js`, applied via `runChat({baseUrl})`→`baseUrl||provider.baseUrl`; every other provider unchanged when blank). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Professional structure (2026-07-10, `ANALYSIS_PRO_STRUCTURE_PLAN.md`):** prompts upgraded to ICD-203/desk-note packaging — Key Judgments box (BLUF, each judgment = yardstick probability term + separate justified confidence), the ICD-203 probability yardstick verbatim, an `| Indicator to watch | Confirms | Kills |` table on the scenario lens, and a `gp-struct` machine-readable block → ScenarioBars/IndicatorMatrix/RippleTable visuals (a chart can only show numbers the prose already states; every rule carries an omit-when-unsupported escape hatch — the 06-13 structure-pressure-induces-fabrication lesson). Server prompt (`newsAnalyze`) synced verbatim; guided maxTokens 1600→2400.\n- **Surfacing (2026-07-09, `ANALYZE_SURFACING_PLAN.md`):** was ONE nav link → now Home per-story \"Analyze →\" pill + hero line, ThreadPage \"Analyze this arc →\", `/economy` cross-link, all deep-linking via `?stories=` preselect.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — **only `newsPolarBilling` and `newsAnalyze` own CORS in code** (`corsHeaders` + an OPTIONS 204 handler); their Function-URL CORS config **must stay empty**, or AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser; bit us at the Polar checkout 2026-06-30). **Corrected 2026-07-25 (staleness audit):** `newsSavedItems` and `newsRecommend` do the **opposite** — their code emits **no** ACAO and they rely on a **populated** Function-URL CORS config. Do **not** clear their Function-URL CORS (that would remove all CORS and break them in-browser). Rule of thumb before touching any function's Function-URL CORS: grep the source for `Access-Control-Allow-Origin` first — code emits it ⇒ Function-URL CORS empty; code doesn't ⇒ Function-URL CORS populated.\n"}, "8b422f653289aa08f3443346a0f108cfde5d569d": {"t": 1785765781.7166955, "n": 185070, "h": "4fd91fb83ca81f0d", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-29 (refresh: **SILENT OUTPUT-TRUNCATION IN `newsSystemsAnalysis` — CEILING RAISED**. The 07-29 run went `7 generated, 3 failed` (`Unterminated string in JSON at position 10929`, Iran + Ukraine): `MAX_TOKENS` was **unset** → code default **3000**, and the graphs had outgrown it. Measured by replaying the real prompts at an 8000 ceiling — Iran **3214** tokens, Ukraine **3062**, US **3038** — i.e. the whole cohort sat in a 3038–3214 band *straddling* the limit, so which countries failed was luck (US passed that morning at 3038 and was already over). Fix = env **`MAX_TOKENS=6000`** (~87% headroom; `max_tokens` is a ceiling, not spend); re-run confirmed Iran + Ukraine now generate. **Silent class again:** caught per-country → exit 200 → CloudWatch `Errors` `0.0`, and neither `newsFreshnessMonitor` (topics only) nor `newsModelGuard` #30 (model ids only) covers it — same family as the 06-01 truncation stall. Also corrected: **`newsPostDevTo` #7 is the sole producer of the `/daily` Daily Brief** (a DeepSeek `callGrok` path entirely separate from its OpenRouter Dev.to article) — the doc had described only the Dev.to half, so nothing pointed a triager at this Lambda when `/daily` empties. Prior: **DEEPSEEK `deepseek-chat` RETIREMENT — FLEET MIGRATED TO V4**. `deepseek-chat` was retired by DeepSeek **2026-07-24**, silently breaking all 11 DeepSeek Lambdas for ~36h (most swallow the LLM error and exit 200, so CloudWatch showed 0 errors — only the freshness monitor's stale-content alert surfaced it). **Fixed 2026-07-26:** added `thinking: { type: 'disabled' }` to each function's DeepSeek request body (V4 defaults to thinking-mode → burns `max_tokens` on invisible `reasoning_content` → truncated/empty output — proven live: v4-flash without the patch clustered only 1 topic at `finish_reason=length`), then flipped `GROK_MODEL` per an intent-based split — **v4-pro** for the 3 open-analytical functions (`newsCountryIntelligence`, `newsSystemsAnalysis`, `newsEconomicImpact`), **v4-flash** for the mechanical/constrained/judge-protected/high-volume rest (`newsInvokeGemini`, `NewsProjectInvokeAgentLambda`, `newsDriftCorrector`, `newsPredictionResolver`, `newsWeeklyBrief`, `newsWeeklyMarkets`-producer, `newsPairIntelligence`, `newsImpactAudit`). Pipeline verified live end-to-end (13 topics → 39 AI items → swap). The `thinking` patch is now **ported into the repo `src/index.js` of all 10 on-main functions** (2026-07-27) so a future zip-from-repo deploy won't regress it. ✅ PPA/property project (same account) also migrated — `PPAcomputeWardProfiles-dev` + `PPAfetchMarketNews-dev` both verified on `LLM_MODEL=deepseek-v4-flash` (2026-07-27). Prior: **AGENT-REVIEW STALENESS AUDIT** — a 5-auditor parallel sweep verified the whole doc against live AWS (`AGENT_REVIEW_METHOD.md`). Fixes landed: (1) **`newsBreakingAlert` #21 is DEPLOYED + scheduled + `DRY_RUN=false`**, not \"BUILT — NOT DEPLOYED\" (still human-confirm-before-send); (2) **4 of 7 EventBridge *Scheduler* entries run `Asia/Tokyo`, not UTC** — `InvokeLinkedIn`/`LinkedinThreadsDaily`/`Fact` corrected (`InvokeDev` was already right); the \"All times UTC\" header was wrong; (3) **`newsPairIntelligence` #8 is on the ENABLED weekly rule `TriggerPairIntelligenceWeekly`**, not manual-only; (4) **Common Mistakes #7 CORS list corrected** — only `newsPolarBilling`+`newsAnalyze` own CORS in code (Function-URL CORS empty); `newsSavedItems`+`newsRecommend` do the opposite (populated Function-URL CORS, no ACAO in code) — clearing theirs would break them; (5) **a Signal-API + impact-first ingest subsystem** (`newsSignals`/`newsImpactAudit`/`newsGdacsIngest`/`newsGdeltConflict` + 6 DDB tables) is live in prod from the unmerged `signal-api` branch — now documented as \"Prod infra NOT on `main`\"; (6) stale counts: proxy actions ~27→~33, components ~64→67, atoms 17→19, hooks 32→34; `newsPostLinkedin` env `SOCIAL_POSTS_TABLE`→deployed `LINKEDIN_POSTS_TABLE`. Backend #10–20 + frontend routes/components/hooks mappings verified CLEAN. ~~Note to reconcile: CLAUDE.md's Dual-CORS bullet + memory `feedback_lambda_function_url_cors` still carry the old 4-function CORS list~~ — **RESOLVED (verified 2026-07-29): both already carry the corrected list** (only `newsPolarBilling`+`newsAnalyze` own CORS in code / empty Function-URL CORS; `newsSavedItems`+`newsRecommend` the opposite), re-confirmed against live `get-function-url-config`. Prior: **ANALYSIS STUDIO — VISUAL-BLOCK RELIABILITY FIX + QWEN PROVIDER** [[project_analysis_studio]]. 07-10 [`4193610`, `QWEN_AND_VISUAL_BLOCK_PLAN.md`]: a **qwen3.7-max vs deepseek-v4-pro bake-off** (same stories/lenses, shipped pipeline) exposed the real \"where's the graph?\" cause — DeepSeek (the member-path model) emitted the `gp-struct` block in only **1/4** runs (writes the scenario prose but skips the machine-readable block), so the 07-10 ScenarioBars/IndicatorMatrix/RippleTable rarely rendered for members; Qwen emitted 4/4. **Fix (prompt-only, `analysisPrompt.js`):** embedded a fully-filled JSON template in SYSTEM_PROMPT + a mandatory tail reminder inside each lens task (where the model finishes) + removed the easy \"omit the whole block\" exit for scenario/economic lenses → valid-block emission **1/4 → 8/8** live (`scratchpad/emit-test.mjs`); honesty intact — `validateStruct` still strips any figure not literally in the prose, so all 8 validated with zero fabrication. `newsAnalyze` Lambda re-synced via the **patched-deployed-zip** method (Node made deployed SYSTEM_PROMPT byte-equal to the frontend, `FE===BE` 5278 chars; deployed bytes verified: template present, omit-exit gone, `creditBalance`=0, `MAX_TOKENS`=2400; lens-task tail reminders ride in via the frontend-built `body.user`). **Bake-off verdict:** DeepSeek stays the member-path default (tighter/MECE probability bands, deeper prose, always states confidence); **Qwen3.7-max added as a faster (~36%) BYOK option** — but Alibaba's international keys are **WORKSPACE-scoped (`sk-ws-…`)** and 401 the generic `dashscope-intl` host, needing `https://<WorkspaceId>.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1` (Tokyo; verified with `ws-gl08109galyxzzh3`), so a per-provider **base-URL override** was added (`allowBaseUrlOverride` on qwen; `runChat`/`runOpenAICompat` take optional `baseUrl`→`baseUrl||provider.baseUrl`; ProviderModal optional \"Endpoint URL\" field; `byok.js` persists it back-compat) — live-tested end-to-end through real `runChat` (override→200, default→401). Separately (same day, prod-config only): the member path itself was switched to **`deepseek-v4-pro`** (`ANALYZE_DAILY_CAP` 50→100, `Timeout` 30s→120s), which required patching `runDeepSeek` to send `thinking:{type:'disabled'}` — v4-pro's default thinking mode otherwise burns the whole `MAX_TOKENS` budget and returns empty output — and surfaced a lesson that a timed-out synchronous Function-URL Lambda returns no-ACAO, which looks like CORS but isn't. Prior: **ANALYSIS STUDIO SURFACED + PROFESSIONAL STRUCTURE**. 07-09 [`8cf2b29`, `ANALYZE_SURFACING_PLAN.md`]: `/analyze` was discoverable from ONE nav link (~2 runs/30d = discovery gap, not quality — engine live-tested 0/3 fabrication, and a manual web truth-check found every auditor flag was a FALSE POSITIVE vs ground truth); surfaced via a per-story **\"Analyze →\"** pill on Home (4th control beside Summary/Predict/Trace), a Home hero value-prop line, ThreadPage **\"Analyze this arc →\"** (deep-links `thread.entries[0].topicId`), an `/economy` cross-link, and a new **`?stories=<id>[,…]` preselect** in `AnalysisStudio.jsx` (seeds once on topics-load, unknown ids silently dropped). 07-10 [`c429931`, `ANALYSIS_PRO_STRUCTURE_PLAN.md`]: output upgraded to ICD-203/desk-note packaging — **Key Judgments box** (BLUF bullets), the **ICD-203 probability yardstick** verbatim (\"very likely (80–95%)\"), **confidence as a separate justified axis**, a consolidated **`| Indicator to watch | Confirms | Kills |` table** on the scenario lens, and a fenced **`gp-struct` JSON block** (a machine-readable INDEX of the prose — never new content) parsed by new `utils/analysisStruct.js` (truncation-safe; drops scenarios whose probability digits aren't literally in the prose) → rendered by `atoms/AnalysisVisuals.jsx` (**ScenarioBars/IndicatorMatrix/RippleTable**, pure CSS). Validator + Markdown always receive the STRIPPED prose. Review caught 4 pre-deploy bugs: `newsAnalyze`'s server-pinned prompt had **DRIFTED** (missing all 06-13 upgrades — members got a weaker analyst than BYOK; now synced verbatim), gp-struct truncation at 1600 tokens (guided maxTokens→2400, Studio+eval+Lambda env), and two validator false-positives from the new structure (comparison-operator thresholds `>50%`; scenario-heading ranges — estimative look-behind 32→48; goldens added). **⚠️ prod `newsAnalyze` deploy = PROMPT-PATCH of the downloaded DEPLOYED (pre-credits 06-16) zip — the repo file carries the PARKED credits code and must NOT reach prod until `PROD_CREDITS_NEXT_STEPS.md` runs.** Prior: **SECOND PREDICTION RESOLUTION RUN + DRIFT-EMAIL PER-AXIS VIEW + FOLLOWBUTTON ON THE LEDGER** [[project_prediction_methodology_v1]] [[project_scoring_model_v2]]. The 07-08 agent-run resolution sweep scored **all 21 due triggers** [incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers from the 07-07 run, cleared on recheck]. **`/track-record` now shows resolved 35 (was 15), fired 9 (was 4), Brier 0.157 (was 0.084)**; calibration buckets `0-20%`→stated ~17%/fired 9% (n11) · `20-40%`→stated ~20%/fired 9% (n11) · `60-80%`→stated ~65%/fired 54% (n13). Brier rose on purpose: run-1 was flattered by easy low-probability tails; run-2 added real high-stakes calls with genuine misses — the honest direction. The 65% bucket runs ~10pts hot (54% actual) but the curve is monotonic (9%→9%→54%). Cadence confirmed weekly agent-in-session. Prior (07-08, both already on main): **drift-alert email → per-axis view** [`0ec494f`, `SCORING_MODEL_V2_PLAN.md` §12] — `newsDriftCorrector` now embeds `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel` onto each new `DRIFT#`/`DRIFTLOG#` country note; `renderDriftEmail.js` renders a degradation ladder (currentDimensions → Level B 4-axis scorecard `HIGH · Humanitarian 82` + `⚠ N/4 elevated` → changeDimensions → Level A per-axis pills → legacy scalar); not retroactive. **FollowButton surfaced on the `/track-record` corrections ledger** [`b455ee1`, `FOLLOW_SURFACING_PLAN.md`] — the member country-follow control (previously CountryPage-only) now also renders as a sibling of the name link on each **country-scope** ledger row, the highest-intent \"get the next change emailed\" moment; management still lives at `/account?tab=notifications`. Deferred surfaces: CountryWhatChanged band (redundant), ThreadPage (multi-country ambiguity), countries-index (nesting/grid). Prior: **FIRST PREDICTION RESOLUTION RUN DONE + G6 SPORTS-SCOPE GATE LIVE** [[project_prediction_methodology_v1]]. The accountability record is no longer empty: the first agent-run resolution loop (`predictions/V1_RESOLUTION_RUNBOOK.md`) scored **16 of 21 due triggers** — 3 parallel blind web-grounded verifiers → 2nd independent blind pass on every `fired` → operator ~10% spot-check → dry-run → `resolve-v1-write.js --commit`. **`/track-record` now shows Brier 0.084**, resolved 15 (fired 4 · not_fired 11) + 1 unclear, calibration buckets 0-20%→0% (n6) · 20-40%→0% (n4) · 60-80%→80% (n5) [caveat: n tiny, 11/15 were low-p tails correctly not firing = easy points]; 5 triggers deferred (window still open on run day — Le Pen ruling ×3, INDOPACOM, Andersen). Deadlines arrived earlier than the ~07-11 estimate (short-horizon triggers). **New capture gate G6** [`NewsProjectInvokeAgentLambda/src/lib.js`, deployed to `-dev` + deployed-bytes verified; source `91e069e` + deploy `73e490d`]: drops **pure sporting-result** triggers — a match/tournament outcome or on-field participation carries no geopolitical/economic/institutional signal (the remit). Rule = drop only when BOTH a sporting-competition **context** regex AND a **result/participation** regex match, so sport-**adjacent** governance triggers survive (no result verb: \"Belgium files a FIFA protest\", \"CAS grants an interim measure\" pass). **Corpus-validated: across all 1038 live v1 triggers G6 drops exactly the 2 Trump–FIFA/Balogun box-score triggers, 0 false positives**; `node --test` 21/21. NOT retroactive (already-published sport triggers stay scored); scope decision was fix-forward at the trigger layer, NOT an ingest ban. Methodology disclosure live on `/track-record`. Cadence = weekly agent-in-session. Prior: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- **DeepSeek V4 (post-2026-07-26 migration — `deepseek-chat` is retired; all send `thinking:{type:'disabled'}`):**\n - **`deepseek-v4-pro`** (deeper reasoning; the open-analytical reads): `newsCountryIntelligence`, `newsSystemsAnalysis`, `newsEconomicImpact`\n - **`deepseek-v4-flash`** ($0.14/M in · $0.28/M out; mechanical/constrained/judge-protected/high-volume): `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsDriftCorrector`, `newsPredictionResolver`, `newsWeeklyBrief`, `newsWeeklyMarkets` (producer call), `newsPairIntelligence`, `newsImpactAudit`\n - `newsPostDevTo` uses **OpenRouter** (`deepseek/deepseek-v4-flash:free`), not the direct DeepSeek API — see #7\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek v4-flash** (migrated 2026-07-26 from the retired `deepseek-chat`; `api.deepseek.com`, `thinking:disabled`). On the weekly rule `TriggerPairIntelligenceWeekly` + ad-hoc manual invokes.\n- `newsAnalyze` → **DeepSeek v4-pro** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=100`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). **DEPLOYED + scheduled** (`TriggerBreakingAlert` 4-hourly, `DRY_RUN=false`; proposes only, human confirms before send) — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~33 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek v4-flash** (migrated 2026-07-26 from the retired `deepseek-chat`; `thinking:disabled`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-v4-flash` in production (was `deepseek-chat` until the 2026-07-26 retirement migration). Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check · **G6 capture-scope — drops pure sporting results, added 2026-07-07**) before it can enter the immutable log — a malformed or out-of-scope trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. G6 fires only when a trigger has BOTH a sporting-competition context AND a result/participation verb (so sport-adjacent governance triggers survive); corpus-validated 2/1038, 0 false positives. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (21/21, uses the real 2026-07-04-pilot defect triggers + the 2026-07-07 Balogun box-score triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-v4-flash` in production (was `deepseek-chat` until the 2026-07-26 retirement migration; `thinking:disabled` in the request body). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. **Two agent-run resolution sweeps done (07-07, 07-08) → live: resolved 35, fired 9, Brier 0.157, calibration buckets populated** (was honest-empty before 07-07) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)`, **timezone `Asia/Tokyo`** (verified 2026-07-25 — the scheduler is NOT in UTC, same as `newsPostDevTo` #7). Fires at :20 past JST hours 0/3/6/… = **15/18/21/00/… UTC**.\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `LINKEDIN_POSTS_TABLE` (=`NewsProject-linkedin-posts`; the deployed var is **not** `SOCIAL_POSTS_TABLE` — code reads `SOCIAL_POSTS_TABLE || LINKEDIN_POSTS_TABLE` so the fallback wins), `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to) — **and, despite the name, is also the sole producer of the Daily Intelligence Brief behind `/daily`.**\n\n> ⚠️ **Misleading name — two independent jobs on two different providers** (corrected 2026-07-29; the doc previously described only the Dev.to half). `/daily` has no other producer, so a failure here silently empties the Daily Brief page while the Dev.to post is unaffected (and vice versa). When triaging `/daily`, look at *this* Lambda, not a \"daily brief\" function — there isn't one.\n> - **`generateAndStoreDailyBrief` → `callGrok`** uses **DeepSeek** via `GROK_MODEL`/`GROK_API_URL`/`XAI_API_KEY` and writes `DAILY_BRIEF#{dateKey}` to `SUMMARIZE_PREDICT_TABLE`. Migrated to `deepseek-v4-flash` + `thinking:{type:'disabled'}` on 2026-07-27 (main `c232624`) — it was the last straggler on the retired `deepseek-chat` alias.\n> - **The Dev.to article** uses **OpenRouter** via `AI_MODEL` — a completely separate call path and key.\n> - The stored brief carries a `model` field sourced from `GROK_MODEL`, so briefs written before 07-27 are labelled `deepseek-chat`.\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. **Builds + stores the Daily Brief** (`DeepSeek`, 50 archive entries + thread/country enrichment) → `DAILY_BRIEF#{dateKey}` / `DAILY_BRIEF`, 90-day TTL. Served by the `daily_brief` action → `/daily`\n3. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n4. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n5. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `XAI_API_KEY` / `GROK_MODEL` (=`deepseek-v4-flash`) / `GROK_API_URL` (the **daily-brief** path — legacy names holding DeepSeek values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPairIntelligenceWeekly` — **ENABLED** — `cron(0 8 ? * MON *)` (Mondays 08:00 UTC; **corrected 2026-07-25 — was documented as manual-only**). Also invocable manually with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for the default 10 pairs.\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** the source default still reads Grok, but the **deployed** function is on DeepSeek (`GROK_MODEL=deepseek-v4-flash` since the 2026-07-26 retirement migration — was `deepseek-chat`; `GROK_API_URL=https://api.deepseek.com/chat/completions`; request body sends `thinking:disabled`). Runs weekly via `TriggerPairIntelligenceWeekly` (see trigger above) plus ad-hoc manual invokes.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`, **`MAX_TOKENS` (=`6000`; code default `3000` — see below)**\n\n> ⚠️ **Output-truncation ceiling (raised 2026-07-29).** `MAX_TOKENS` was **unset**, so this ran on the code default **3000** — and the graphs outgrew it. On 07-29 the run went `7 generated, 3 failed` with `Failed to parse Grok response: Unterminated string in JSON at position 10929` for Iran/Ukraine. **Measured** (replaying the real prompts at an 8000 ceiling): Iran needs **3214** completion tokens, Ukraine **3062**, United States **3038** — the whole 12-country cohort had drifted into a 3038–3214 band straddling the 3000 limit, so which countries failed was luck, not content (US passed that day at 3038 and was already over). Truncation arithmetic confirms it exactly: 12032 full chars − 10929 clipped = 1103 chars ≈ the 214-token overshoot. Fix = env `MAX_TOKENS=6000` (~87% headroom over the measured peak); `max_tokens` is a **ceiling, not spend**, so cost is unchanged. **Silent class:** failures are caught per-country and the function exits 200, so CloudWatch `Errors` stayed `0.0` — and neither `newsFreshnessMonitor` (watches `topics` only) nor `newsModelGuard` (#30, model ids only) covers this. Same family as the 06-01 `newsInvokeGemini` JSON-truncation stall; if graphs later exceed ~20 threads, port that function's `salvageTruncatedTopics()` pattern here (this one's `JSON.parse` has no salvage branch).\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` — DEPLOYED + SCHEDULED (proposal mode; `DRY_RUN=false`)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerBreakingAlert` — **ENABLED** — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis).\n\n> ⚠️ **Doc corrected 2026-07-25 (agent-review staleness audit):** this function was long documented as \"BUILT — NOT DEPLOYED (dry-run)\". Live AWS shows it is **deployed** (Timeout 120s / 256MB), driven by the **ENABLED** rule above, wired to the `GlobalPerspectiveBreakingAlerts` table, with **`DRY_RUN=false`**. What is still true is the **human-in-the-loop / never-auto-sends** design: even at `DRY_RUN=false` the code only writes a `status:'proposed'` row and logs \"awaiting human confirmation before send\" — the actual email send lives in `newsEmailSender` breaking mode (#29), gated on a human `status:'confirmed'`. So \"proposes automatically, sends only after human confirm,\" **not** \"dry-run/un-deployed.\"\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` runs live on its 4-hourly schedule writing structured `status:'proposed'` records; legacy confirmed records render via the saved-email-text fallback in `get_alert`.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (deployed, verified 2026-07-25):** `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE` (=`SummarizeAndPredict`), `BREAKING_ALERTS_TABLE` (=`GlobalPerspectiveBreakingAlerts`), `RESEND_API_KEY` (set), `SITE_URL`, `DRY_RUN` (=`false`), plus `ALERT_EMAIL_FROM` (=`onboarding@resend.dev`) and `ALERT_EMAIL_TO` (operator address). `SIGNIFICANCE_THRESHOLD`/`DEDUPE_DAYS` are **not** set → code defaults only. No Function URL (this is a scheduled Lambda, not URL-invoked).\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars (deployed, verified 2026-07-25):** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `USERS_DDB_TABLE` (=`GlobalPerspectiveUserTable`, added for the follow-country tier check), `FIREBASE_PROJECT_ID`. (`BREAKING_ALERTS_TABLE` and `SITE_URL` are **not** set in the deployed env — code falls back to literals.) **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered. Country-note fields: `changeScore`/`changeLevel`/`changeDimensions{axis:{from,to,delta}}` (what moved) + `priorHeadline`/`currentHeadline` + `triggerEvent`/`whyChanged`/`noSingleDriver`, **and (scoring-v2 Phase D-email, 2026-07-08) the current standing `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel`** for the email's Level-B scorecard (the vector was already loaded for `changeDimensions`; not backfilled — new notes only).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**⚠️ DEPLOYED ≠ REPO (2026-07-10):** prod still runs the **pre-credits 06-16 code** (gate = `ANALYZE_DAILY_CAP=50`) with the **P1 professional-structure SYSTEM_PROMPT patched in** (2026-07-10: downloaded the deployed zip, patched ONLY the prompt block, redeployed; `MAX_TOKENS` env 1600→2400 for the longer structured output). The server-pinned prompt is now mirrored **verbatim** from the frontend's `utils/analysisPrompt.js` — it had silently drifted (missing all 06-13 upgrades, so member runs got a weaker analyst than BYOK). **Re-synced again 2026-07-10 (`4193610`)** with the **gp-struct visual-block reliability fix** (embedded JSON template + emission mandate — raised valid-block emission on the DeepSeek member path from 1/4 to 8/8, so the ScenarioBars/etc. now actually render for members); the lens-task tail reminders reach the model via the frontend-built `body.user` (the Lambda only holds `SYSTEM_PROMPT`), so syncing that array was sufficient. Same patched-zip method: `FE===BE` verified byte-for-byte (5278 chars). **Never zip the repo `src/index.js` to prod until `PROD_CREDITS_NEXT_STEPS.md` runs** — it carries the parked credits code, and deploying it without the env-ordering steps can mis-grant orders. **Model switch + timeout fix (2026-07-10, prod-config only):** the member-path model was switched `deepseek-chat` (V4 Flash) → **`deepseek-v4-pro`** (env `GROK_MODEL`, the bake-off's stronger analyst), with a required code patch (same patched-deployed-zip method) sending `thinking:{type:'disabled'}` in `runDeepSeek` — v4-pro defaults to thinking mode, which without this burns the whole `MAX_TOKENS` budget on `reasoning_content` and returns **empty output** (`len=0, finish_reason=length`, proven live before the fix; code patch deployed before the env flip so there was no broken window). `ANALYZE_DAILY_CAP` raised 50→100 and `Timeout` raised 30s→120s (v4-pro takes ~40–60s; the old 30s timeout surfaced in-browser as a fake-CORS **\"Failed to fetch\" / \"No 'Access-Control-Allow-Origin' header\"** error — the Function-URL CORS config was correctly empty throughout, so this was NOT the dual-CORS trap: on a synchronous Function-URL Lambda, a timed-out invocation returns a bare platform error with no CORS headers, which reads as CORS but is actually a timeout).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live). **Per-axis view — scoring-v2 Phase D-email (2026-07-08, `SCORING_MODEL_V2_PLAN.md` §12):** `renderDriftEmail` renders a degradation ladder — `note.currentDimensions` → **Level B** compact scorecard (`HIGH · Humanitarian 82` + 4-axis row + `⚠ N/4 elevated`); else `changeDimensions` → **Level A** per-axis change pills; else the legacy scalar. No frontend-util import (reads the note's derived headline fields; breadth flag inline). Not retroactive — Level B only for notes written after the corrector deploy.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n### Prod infra NOT on `main` — Signal-API + impact-first ingest (documented 2026-07-25)\n\nFour Lambdas are **deployed and ENABLED in prod** but their **source lives only on the unmerged `signal-api` branch/worktree**, so they had no entry here. They pair with 4 undocumented DDB tables (below). Flagged by the 2026-07-25 staleness audit; source not on `main` means the deployed-bytes are the only reference — verify against live AWS before changing them. See [[project_signal_api_deployed]], [[project_impact_first_redesign]], `signal-api` branch.\n\n| Function | Trigger (ENABLED) | Config | Role (from deployed env + tables) |\n|---|---|---|---|\n| **`newsSignals`** | `TriggerSignalsBuild` `cron(0 10 * * ? *)` + a public **Function URL** (AuthType NONE, key-gated in code) | 300s / 512MB | Signal-API v1 builder — writes `GlobalPerspectiveSignals` (120d TTL) from `PredictionLog`/`SummarizeAndPredict`/`BreakingAlerts`; `API_KEYS_TABLE=GlobalPerspectiveApiKeys`, `FREE_DELAY_HOURS=24` (paid-tier freshness gate). |\n| **`newsImpactAudit`** | `TriggerImpactAudit` `cron(0 9 * * ? *)` | 120s / 256MB | Coverage dead-man's-switch — DeepSeek audit of whether high-impact events were MISSED; `IMPACT_AUDIT_TABLE`/`INGEST_CAPTURE_TABLE`, SNS→`GlobalPerspectiveAlerts` when misses ≥ `MISS_ALERT_THRESHOLD=2`. |\n| **`newsGdacsIngest`** | `TriggerGdacsIngest` `cron(0 */6 * * ? *)` | 120s / 256MB | GDACS disaster-alert ingest feed → `GlobalPerspectiveGdacsEvents`. |\n| **`newsGdeltConflict`** | `TriggerGdeltConflict` `cron(0 */6 * * ? *)` | 120s / 512MB | GDELT conflict-event ingest feed → `GlobalPerspectiveGdeltConflict`. |\n\n---\n\n### 30. `newsModelGuard` — model-retirement dead-man's-switch (LIVE 2026-07-28)\n**Path:** `amplify/backend/function/newsModelGuard/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerModelGuard` — **ENABLED** — `cron(0 12 * * ? *)` (daily 12:00 UTC). **No LLM** (one cheap authenticated `GET /models`).\n\nThe passive guard against a repeat of the **2026-07-24 `deepseek-chat` retirement** (which became a 36h silent outage because nothing was watching the deadline — see `BACKEND_DEEPSEEK_V4_MIGRATION_PLAN.md`). Daily it `ListFunctions` across the whole account, reads each function's env for model-ID vars (`GROK_MODEL`/`LLM_MODEL`/`MODEL`/`AUDIT_MODEL`/`JUDGE_MODEL`/`PPLX_MODEL`/`AI_MODEL`), and flags any **direct DeepSeek id** (`/^deepseek-/`; the OpenRouter `deepseek/…` namespaced form is skipped) that is either a **deprecated alias** (`deepseek-chat`/`deepseek-reasoner`) or **absent from DeepSeek's live `/models` list** (already retired/invalid). Findings → SNS `GlobalPerspectiveAlerts` with the proven fix recipe; **honest-empty** (silent when all clear). Skips `*-sandbox` functions + `IGNORE_FUNCTIONS` to avoid nagging on throwaway test envs. **Known gap:** env-scan only — a function hitting the retired model via a hardcoded code default (`process.env.X || 'deepseek-chat'`) is invisible here; those were separately hardened to `deepseek-v4-flash` defaults 2026-07-28.\n\n**Key env vars:** `DEEPSEEK_API_KEY` (copied from the fleet — auths the `/models` call), `DEEPSEEK_MODELS_URL` (=`https://api.deepseek.com/models`), `SNS_TOPIC_ARN` (=`GlobalPerspectiveAlerts`), optional `MODEL_VARS`/`DEPRECATED_ALIASES`/`IGNORE_FUNCTIONS`. **Role:** `newsModelGuard-role` (basic-exec logs + `lambda:ListFunctions`/`GetFunctionConfiguration` account-wide read + `sns:Publish` to the alerts topic only).\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch); **`newsModelGuard` (#30)** alerts when any Lambda is left on a deprecated/retired DeepSeek model (the model-retirement dead-man's-switch, born from the 2026-07-24 `deepseek-chat` outage). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d, in-scope — G6 drops pure sporting results from 2026-07-07) and gated-out triggers are recorded in `capture.dropped` with their `gate`; a scenario with no surviving dated trigger carries `scoreable:false`. `finalVerdict`/`confirmedBy:'agent-verified'` written by the Phase-2 agent loop (first run 2026-07-07). Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Populated live by the scheduled detector (`newsBreakingAlert` runs 4-hourly, `DRY_RUN=false`) — see [Lambda #21](#21-newsbreakingalert--deployed--scheduled-proposal-mode-dry_runfalse) + `BREAKING_ALERTS_PLAN.md`.\n\n### Signal-API + ingest tables (`signal-api` branch — live in prod, documented 2026-07-25)\nThese exist in prod DynamoDB but their producers' source is on the unmerged `signal-api` branch (see \"Prod infra NOT on `main`\" under the Lambda inventory). Key schemas confirmed via `describe-table` 2026-07-25:\n\n| Table | Key | Written by | Contents |\n|-------|-----|-----------|----------|\n| `GlobalPerspectiveSignals` | `signal_id` | newsSignals | Signal-API v1 records (120d TTL) |\n| `GlobalPerspectiveApiKeys` | `keyHash` | mint-key.mjs / newsSignals | Signal-API access keys |\n| `GlobalPerspectiveGdacsEvents` | (see describe-table) | newsGdacsIngest | GDACS disaster-alert ingest |\n| `GlobalPerspectiveGdeltConflict` | (see describe-table) | newsGdeltConflict | GDELT conflict-event ingest |\n| `GlobalPerspectiveImpactAudit` | (see describe-table) | newsImpactAudit | Coverage-audit results |\n| `GlobalPerspectiveIngestCapture` | (see describe-table) | ingest feeds | Raw high-impact event capture (audited by newsImpactAudit) |\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G6** (`src/lib.js`; G6 = sporting-result scope, added 2026-07-07) → structured, forward-dated, in-scope, `methodologyVersion:1` triggers; malformed/out-of-scope ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4, runbook `predictions/V1_RESOLUTION_RUNBOOK.md`): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface. **Two runs done (weekly cadence):** 07-07 scored 16 of 21 due triggers (5 deferred, window still open on run day); **07-08 scored all 21 due triggers** (incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers, cleared on recheck). Same-day-deadline learning: verifiers need a \"defer\" option when the event window hasn't closed (not yet folded into the runbook doc). Live totals: resolved 35, fired 9, Brier 0.157 (up from 0.084 after run 1 — run 2 added real high-stakes calls with genuine misses, the honest direction; the 60-80% bucket runs ~10pts hot but the curve stays monotonic).\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n5. **Feedback (Phase 5, DESIGNED 2026-07-08 — data-gated, NOT built)** — `CALIBRATION_DIGEST_PLAN.md`: a weekly `newsCalibrationDigest` Lambda will compact the resolved-trigger trace into one ~200-token `CALIBRATION#DIGEST` row (probability-bucket calibration + per-reference-class fire rates + deterministic bias lines); generation GetItems it and injects a `calibrationBlock` into `buildPredictionPrompt`, stamping `methodologyVersion:2` (the flat-65% v1 era stays as control group). Build gates: CP-1 manual measure at ~50 resolved; CP-2 infra at ~150 resolved. Honesty rule: feedback changes what the model says NEXT, never remaps stored/displayed probabilities. Digest rows must NOT carry `methodologyVersion` (the extract script and `prediction_track_record` filter on it).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-07-25)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. **EventBridge Rules run in UTC; several EventBridge *Scheduler* entries run in `Asia/Tokyo` — do NOT assume UTC for the Scheduler table** (the \"All times UTC\" claim was wrong pre-2026-07-25: 4 of 7 schedulers are JST — see the timezone column below).\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `TriggerModelGuard` | `cron(0 12 * * ? *)` | newsModelGuard (#30 — daily model-retirement guard; SNS-alerts on deprecated/retired DeepSeek model) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n| `TriggerPairIntelligenceWeekly` | `cron(0 8 ? * MON *)` | newsPairIntelligence (#8 — Mondays 08:00 UTC; **NOT manual-only**, corrected 2026-07-25) |\n| `TriggerBreakingAlert` | `cron(15 */4 * * ? *)` | newsBreakingAlert (#21 — 4-hourly, ENABLED, `DRY_RUN=false`) |\n| `TriggerDriftCorrector` | `cron(20 7 * * ? *)` | newsDriftCorrector (#26 — 07:20 UTC daily) |\n| `TriggerWeeklyEmailSend` | `cron(0 14 ? * SUN *)` | newsEmailSender (#29 — weekly digest, ENABLED) |\n| `TriggerBreakingEmailSend` | `rate(15 minutes)` | newsEmailSender (#29 — breaking sends, ENABLED) |\n| `TriggerDriftEmailSend` | `cron(40 7 * * ? *)` | newsEmailSender (#29 — drift alerts, **DISABLED** by design) |\n| `TriggerFreshnessMonitor` | `cron(30 0/2 * * ? *)` | newsFreshnessMonitor (#18) |\n| `TriggerErrorDigest` | `cron(15 0/6 * * ? *)` | newsErrorDigest (#19) |\n| `TriggerSignalsBuild` | `cron(0 10 * * ? *)` | newsSignals (Signal-API — `signal-api` branch, deployed to prod; see \"Prod infra not on `main`\") |\n| `TriggerImpactAudit` | `cron(0 9 * * ? *)` | newsImpactAudit (impact-first — `signal-api` branch) |\n| `TriggerGdacsIngest` | `cron(0 */6 * * ? *)` | newsGdacsIngest (GDACS ingest — `signal-api` branch) |\n| `TriggerGdeltConflict` | `cron(0 */6 * * ? *)` | newsGdeltConflict (GDELT ingest — `signal-api` branch) |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` **TZ Asia/Tokyo** | newsPostLinkedin (:20 past JST hours 0/3/6/… = **15/18/21/00/… UTC** — NOT UTC, corrected 2026-07-25) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` **TZ Asia/Tokyo** | linkedInAutoPost (07:30 / 19:30 **JST** = **22:30 / 10:30 UTC** — corrected 2026-07-25) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` **TZ Asia/Tokyo** | newsCountryFactsUpdater (05:00 **JST** = **20:00 UTC** prior day — corrected 2026-07-25) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual / user-triggered only)\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST / Function URL\n- (`newsPairIntelligence` was listed here as \"manual only\" — **corrected 2026-07-25**: it is now on the ENABLED weekly rule `TriggerPairIntelligenceWeekly`, Mondays 08:00 UTC.)\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, **deployed + scheduled**), newsEmailSender (#29) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~67 component files (in `src/components/` + 19 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then **three** stacked layer lenses: **Today's pulse** (24h fresh-news rings), **Connections** (bilateral arcs from `usePairAnalyses`), **Editorial** (top-5-by-signal numbered pins). Base country fills + two-tier signal markers are always drawn from `useCountrySignal` (a **client-side 30-day z-score** over `useWeeklyArchive`, bucketed H ≥ 1.5σ · E ≥ 0.5σ, forced quiet under 2 articles — coverage-volume anomaly, deliberately distinct from the backend `riskScore`/tier shown in the detail panel). A 4th **Economy** lens (disruption-exposure rings) was **removed 2026-07-29**: its `econByISO` lookup missed `nameToISO`'s lowercased keys, so it had never rendered a ring, and an always-empty exposure layer reads as \"no exposure anywhere\" ([[feedback_no_misinformation_fallback]]). Disruption data still reaches this page via the lede band and the detail panel's Economic Disruption section. Rendering is **imperative d3** — `drawMap()` wipes and re-appends the whole SVG on a 240ms-debounced effect, so live data reaches it through state-mirrored refs; geometry is `world-atlas@2/countries-110m` fetched from jsDelivr **at runtime**, not bundled |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. **Corrections ledger now also renders `FollowButton` on country-scope rows** (2026-07-08, `FOLLOW_SURFACING_PLAN.md`, `b455ee1`) — sibling of the name link; `FollowButton` was CountryPage-only until now. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). **07-09:** accepts `?stories=<id>[,…]` preselect (seeds once on topics-load; unknown ids dropped) — deep-linked from Home per-story \"Analyze →\", ThreadPage \"Analyze this arc\", `/economy`. **07-10:** output is ICD-203-structured (Key Judgments + yardstick + confidence axis + indicators table); the trailing `gp-struct` JSON block is extracted by `utils/analysisStruct.js` (validator/Markdown get stripped prose) and rendered as `atoms/AnalysisVisuals.jsx` **ScenarioBars/IndicatorMatrix/RippleTable** (struct null → prose-only honest fallback). **07-10 (later, `4193610`):** prompt fix raised DeepSeek's `gp-struct` emission 1/4→8/8 so the visuals render reliably; **Alibaba Qwen** added to the provider list with a workspace base-URL override for `sk-ws-` keys. |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n34 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter / **Alibaba Qwen**) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Qwen (added 2026-07-10)** — flagship `qwen3.7-max` (+`qwen3-max`/`qwen3.7-plus`), `enable_thinking:false` (same non-streaming reason as DeepSeek V4); its keyHint/comment flag that Alibaba **workspace-scoped `sk-ws-…` keys** 401 the default `dashscope-intl` host and need the WorkspaceId-specific `…maas.aliyuncs.com` host → handled by `allowBaseUrlOverride` + the optional \"Endpoint URL\" field in `ProviderModal` (persisted in `byok.js`, applied via `runChat({baseUrl})`→`baseUrl||provider.baseUrl`; every other provider unchanged when blank). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Professional structure (2026-07-10, `ANALYSIS_PRO_STRUCTURE_PLAN.md`):** prompts upgraded to ICD-203/desk-note packaging — Key Judgments box (BLUF, each judgment = yardstick probability term + separate justified confidence), the ICD-203 probability yardstick verbatim, an `| Indicator to watch | Confirms | Kills |` table on the scenario lens, and a `gp-struct` machine-readable block → ScenarioBars/IndicatorMatrix/RippleTable visuals (a chart can only show numbers the prose already states; every rule carries an omit-when-unsupported escape hatch — the 06-13 structure-pressure-induces-fabrication lesson). Server prompt (`newsAnalyze`) synced verbatim; guided maxTokens 1600→2400.\n- **Surfacing (2026-07-09, `ANALYZE_SURFACING_PLAN.md`):** was ONE nav link → now Home per-story \"Analyze →\" pill + hero line, ThreadPage \"Analyze this arc →\", `/economy` cross-link, all deep-linking via `?stories=` preselect.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — **only `newsPolarBilling` and `newsAnalyze` own CORS in code** (`corsHeaders` + an OPTIONS 204 handler); their Function-URL CORS config **must stay empty**, or AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser; bit us at the Polar checkout 2026-06-30). **Corrected 2026-07-25 (staleness audit):** `newsSavedItems` and `newsRecommend` do the **opposite** — their code emits **no** ACAO and they rely on a **populated** Function-URL CORS config. Do **not** clear their Function-URL CORS (that would remove all CORS and break them in-browser). Rule of thumb before touching any function's Function-URL CORS: grep the source for `Access-Control-Allow-Origin` first — code emits it ⇒ Function-URL CORS empty; code doesn't ⇒ Function-URL CORS populated.\n"}, "d14a2ff07ddf93d09fd6b756b81322222687f237": {"t": 1785765783.6204045, "n": 180783, "h": "bb64ebfd46a8fe19", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-27 (refresh: **DEEPSEEK `deepseek-chat` RETIREMENT — FLEET MIGRATED TO V4**. `deepseek-chat` was retired by DeepSeek **2026-07-24**, silently breaking all 11 DeepSeek Lambdas for ~36h (most swallow the LLM error and exit 200, so CloudWatch showed 0 errors — only the freshness monitor's stale-content alert surfaced it). **Fixed 2026-07-26:** added `thinking: { type: 'disabled' }` to each function's DeepSeek request body (V4 defaults to thinking-mode → burns `max_tokens` on invisible `reasoning_content` → truncated/empty output — proven live: v4-flash without the patch clustered only 1 topic at `finish_reason=length`), then flipped `GROK_MODEL` per an intent-based split — **v4-pro** for the 3 open-analytical functions (`newsCountryIntelligence`, `newsSystemsAnalysis`, `newsEconomicImpact`), **v4-flash** for the mechanical/constrained/judge-protected/high-volume rest (`newsInvokeGemini`, `NewsProjectInvokeAgentLambda`, `newsDriftCorrector`, `newsPredictionResolver`, `newsWeeklyBrief`, `newsWeeklyMarkets`-producer, `newsPairIntelligence`, `newsImpactAudit`). Pipeline verified live end-to-end (13 topics → 39 AI items → swap). The `thinking` patch is now **ported into the repo `src/index.js` of all 10 on-main functions** (2026-07-27) so a future zip-from-repo deploy won't regress it. ⚠️ Still on the dead model (separate PPA/property project, same account): `PPAcomputeWardProfiles-dev`, `PPAfetchMarketNews-dev` — patch prepared, deploy pending operator OK. Prior: **AGENT-REVIEW STALENESS AUDIT** — a 5-auditor parallel sweep verified the whole doc against live AWS (`AGENT_REVIEW_METHOD.md`). Fixes landed: (1) **`newsBreakingAlert` #21 is DEPLOYED + scheduled + `DRY_RUN=false`**, not \"BUILT — NOT DEPLOYED\" (still human-confirm-before-send); (2) **4 of 7 EventBridge *Scheduler* entries run `Asia/Tokyo`, not UTC** — `InvokeLinkedIn`/`LinkedinThreadsDaily`/`Fact` corrected (`InvokeDev` was already right); the \"All times UTC\" header was wrong; (3) **`newsPairIntelligence` #8 is on the ENABLED weekly rule `TriggerPairIntelligenceWeekly`**, not manual-only; (4) **Common Mistakes #7 CORS list corrected** — only `newsPolarBilling`+`newsAnalyze` own CORS in code (Function-URL CORS empty); `newsSavedItems`+`newsRecommend` do the opposite (populated Function-URL CORS, no ACAO in code) — clearing theirs would break them; (5) **a Signal-API + impact-first ingest subsystem** (`newsSignals`/`newsImpactAudit`/`newsGdacsIngest`/`newsGdeltConflict` + 6 DDB tables) is live in prod from the unmerged `signal-api` branch — now documented as \"Prod infra NOT on `main`\"; (6) stale counts: proxy actions ~27→~33, components ~64→67, atoms 17→19, hooks 32→34; `newsPostLinkedin` env `SOCIAL_POSTS_TABLE`→deployed `LINKEDIN_POSTS_TABLE`. Backend #10–20 + frontend routes/components/hooks mappings verified CLEAN. Note to reconcile: **CLAUDE.md's Dual-CORS bullet + memory `feedback_lambda_function_url_cors` still carry the old 4-function CORS list** (fix #4 above). Prior: **ANALYSIS STUDIO — VISUAL-BLOCK RELIABILITY FIX + QWEN PROVIDER** [[project_analysis_studio]]. 07-10 [`4193610`, `QWEN_AND_VISUAL_BLOCK_PLAN.md`]: a **qwen3.7-max vs deepseek-v4-pro bake-off** (same stories/lenses, shipped pipeline) exposed the real \"where's the graph?\" cause — DeepSeek (the member-path model) emitted the `gp-struct` block in only **1/4** runs (writes the scenario prose but skips the machine-readable block), so the 07-10 ScenarioBars/IndicatorMatrix/RippleTable rarely rendered for members; Qwen emitted 4/4. **Fix (prompt-only, `analysisPrompt.js`):** embedded a fully-filled JSON template in SYSTEM_PROMPT + a mandatory tail reminder inside each lens task (where the model finishes) + removed the easy \"omit the whole block\" exit for scenario/economic lenses → valid-block emission **1/4 → 8/8** live (`scratchpad/emit-test.mjs`); honesty intact — `validateStruct` still strips any figure not literally in the prose, so all 8 validated with zero fabrication. `newsAnalyze` Lambda re-synced via the **patched-deployed-zip** method (Node made deployed SYSTEM_PROMPT byte-equal to the frontend, `FE===BE` 5278 chars; deployed bytes verified: template present, omit-exit gone, `creditBalance`=0, `MAX_TOKENS`=2400; lens-task tail reminders ride in via the frontend-built `body.user`). **Bake-off verdict:** DeepSeek stays the member-path default (tighter/MECE probability bands, deeper prose, always states confidence); **Qwen3.7-max added as a faster (~36%) BYOK option** — but Alibaba's international keys are **WORKSPACE-scoped (`sk-ws-…`)** and 401 the generic `dashscope-intl` host, needing `https://<WorkspaceId>.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1` (Tokyo; verified with `ws-gl08109galyxzzh3`), so a per-provider **base-URL override** was added (`allowBaseUrlOverride` on qwen; `runChat`/`runOpenAICompat` take optional `baseUrl`→`baseUrl||provider.baseUrl`; ProviderModal optional \"Endpoint URL\" field; `byok.js` persists it back-compat) — live-tested end-to-end through real `runChat` (override→200, default→401). Separately (same day, prod-config only): the member path itself was switched to **`deepseek-v4-pro`** (`ANALYZE_DAILY_CAP` 50→100, `Timeout` 30s→120s), which required patching `runDeepSeek` to send `thinking:{type:'disabled'}` — v4-pro's default thinking mode otherwise burns the whole `MAX_TOKENS` budget and returns empty output — and surfaced a lesson that a timed-out synchronous Function-URL Lambda returns no-ACAO, which looks like CORS but isn't. Prior: **ANALYSIS STUDIO SURFACED + PROFESSIONAL STRUCTURE**. 07-09 [`8cf2b29`, `ANALYZE_SURFACING_PLAN.md`]: `/analyze` was discoverable from ONE nav link (~2 runs/30d = discovery gap, not quality — engine live-tested 0/3 fabrication, and a manual web truth-check found every auditor flag was a FALSE POSITIVE vs ground truth); surfaced via a per-story **\"Analyze →\"** pill on Home (4th control beside Summary/Predict/Trace), a Home hero value-prop line, ThreadPage **\"Analyze this arc →\"** (deep-links `thread.entries[0].topicId`), an `/economy` cross-link, and a new **`?stories=<id>[,…]` preselect** in `AnalysisStudio.jsx` (seeds once on topics-load, unknown ids silently dropped). 07-10 [`c429931`, `ANALYSIS_PRO_STRUCTURE_PLAN.md`]: output upgraded to ICD-203/desk-note packaging — **Key Judgments box** (BLUF bullets), the **ICD-203 probability yardstick** verbatim (\"very likely (80–95%)\"), **confidence as a separate justified axis**, a consolidated **`| Indicator to watch | Confirms | Kills |` table** on the scenario lens, and a fenced **`gp-struct` JSON block** (a machine-readable INDEX of the prose — never new content) parsed by new `utils/analysisStruct.js` (truncation-safe; drops scenarios whose probability digits aren't literally in the prose) → rendered by `atoms/AnalysisVisuals.jsx` (**ScenarioBars/IndicatorMatrix/RippleTable**, pure CSS). Validator + Markdown always receive the STRIPPED prose. Review caught 4 pre-deploy bugs: `newsAnalyze`'s server-pinned prompt had **DRIFTED** (missing all 06-13 upgrades — members got a weaker analyst than BYOK; now synced verbatim), gp-struct truncation at 1600 tokens (guided maxTokens→2400, Studio+eval+Lambda env), and two validator false-positives from the new structure (comparison-operator thresholds `>50%`; scenario-heading ranges — estimative look-behind 32→48; goldens added). **⚠️ prod `newsAnalyze` deploy = PROMPT-PATCH of the downloaded DEPLOYED (pre-credits 06-16) zip — the repo file carries the PARKED credits code and must NOT reach prod until `PROD_CREDITS_NEXT_STEPS.md` runs.** Prior: **SECOND PREDICTION RESOLUTION RUN + DRIFT-EMAIL PER-AXIS VIEW + FOLLOWBUTTON ON THE LEDGER** [[project_prediction_methodology_v1]] [[project_scoring_model_v2]]. The 07-08 agent-run resolution sweep scored **all 21 due triggers** [incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers from the 07-07 run, cleared on recheck]. **`/track-record` now shows resolved 35 (was 15), fired 9 (was 4), Brier 0.157 (was 0.084)**; calibration buckets `0-20%`→stated ~17%/fired 9% (n11) · `20-40%`→stated ~20%/fired 9% (n11) · `60-80%`→stated ~65%/fired 54% (n13). Brier rose on purpose: run-1 was flattered by easy low-probability tails; run-2 added real high-stakes calls with genuine misses — the honest direction. The 65% bucket runs ~10pts hot (54% actual) but the curve is monotonic (9%→9%→54%). Cadence confirmed weekly agent-in-session. Prior (07-08, both already on main): **drift-alert email → per-axis view** [`0ec494f`, `SCORING_MODEL_V2_PLAN.md` §12] — `newsDriftCorrector` now embeds `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel` onto each new `DRIFT#`/`DRIFTLOG#` country note; `renderDriftEmail.js` renders a degradation ladder (currentDimensions → Level B 4-axis scorecard `HIGH · Humanitarian 82` + `⚠ N/4 elevated` → changeDimensions → Level A per-axis pills → legacy scalar); not retroactive. **FollowButton surfaced on the `/track-record` corrections ledger** [`b455ee1`, `FOLLOW_SURFACING_PLAN.md`] — the member country-follow control (previously CountryPage-only) now also renders as a sibling of the name link on each **country-scope** ledger row, the highest-intent \"get the next change emailed\" moment; management still lives at `/account?tab=notifications`. Deferred surfaces: CountryWhatChanged band (redundant), ThreadPage (multi-country ambiguity), countries-index (nesting/grid). Prior: **FIRST PREDICTION RESOLUTION RUN DONE + G6 SPORTS-SCOPE GATE LIVE** [[project_prediction_methodology_v1]]. The accountability record is no longer empty: the first agent-run resolution loop (`predictions/V1_RESOLUTION_RUNBOOK.md`) scored **16 of 21 due triggers** — 3 parallel blind web-grounded verifiers → 2nd independent blind pass on every `fired` → operator ~10% spot-check → dry-run → `resolve-v1-write.js --commit`. **`/track-record` now shows Brier 0.084**, resolved 15 (fired 4 · not_fired 11) + 1 unclear, calibration buckets 0-20%→0% (n6) · 20-40%→0% (n4) · 60-80%→80% (n5) [caveat: n tiny, 11/15 were low-p tails correctly not firing = easy points]; 5 triggers deferred (window still open on run day — Le Pen ruling ×3, INDOPACOM, Andersen). Deadlines arrived earlier than the ~07-11 estimate (short-horizon triggers). **New capture gate G6** [`NewsProjectInvokeAgentLambda/src/lib.js`, deployed to `-dev` + deployed-bytes verified; source `91e069e` + deploy `73e490d`]: drops **pure sporting-result** triggers — a match/tournament outcome or on-field participation carries no geopolitical/economic/institutional signal (the remit). Rule = drop only when BOTH a sporting-competition **context** regex AND a **result/participation** regex match, so sport-**adjacent** governance triggers survive (no result verb: \"Belgium files a FIFA protest\", \"CAS grants an interim measure\" pass). **Corpus-validated: across all 1038 live v1 triggers G6 drops exactly the 2 Trump–FIFA/Balogun box-score triggers, 0 false positives**; `node --test` 21/21. NOT retroactive (already-published sport triggers stay scored); scope decision was fix-forward at the trigger layer, NOT an ingest ban. Methodology disclosure live on `/track-record`. Cadence = weekly agent-in-session. Prior: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- **DeepSeek V4 (post-2026-07-26 migration — `deepseek-chat` is retired; all send `thinking:{type:'disabled'}`):**\n - **`deepseek-v4-pro`** (deeper reasoning; the open-analytical reads): `newsCountryIntelligence`, `newsSystemsAnalysis`, `newsEconomicImpact`\n - **`deepseek-v4-flash`** ($0.14/M in · $0.28/M out; mechanical/constrained/judge-protected/high-volume): `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsDriftCorrector`, `newsPredictionResolver`, `newsWeeklyBrief`, `newsWeeklyMarkets` (producer call), `newsPairIntelligence`, `newsImpactAudit`\n - `newsPostDevTo` uses **OpenRouter** (`deepseek/deepseek-v4-flash:free`), not the direct DeepSeek API — see #7\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek v4-flash** (migrated 2026-07-26 from the retired `deepseek-chat`; `api.deepseek.com`, `thinking:disabled`). On the weekly rule `TriggerPairIntelligenceWeekly` + ad-hoc manual invokes.\n- `newsAnalyze` → **DeepSeek v4-pro** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=100`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). **DEPLOYED + scheduled** (`TriggerBreakingAlert` 4-hourly, `DRY_RUN=false`; proposes only, human confirms before send) — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~33 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek v4-flash** (migrated 2026-07-26 from the retired `deepseek-chat`; `thinking:disabled`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-v4-flash` in production (was `deepseek-chat` until the 2026-07-26 retirement migration). Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check · **G6 capture-scope — drops pure sporting results, added 2026-07-07**) before it can enter the immutable log — a malformed or out-of-scope trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. G6 fires only when a trigger has BOTH a sporting-competition context AND a result/participation verb (so sport-adjacent governance triggers survive); corpus-validated 2/1038, 0 false positives. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (21/21, uses the real 2026-07-04-pilot defect triggers + the 2026-07-07 Balogun box-score triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-v4-flash` in production (was `deepseek-chat` until the 2026-07-26 retirement migration; `thinking:disabled` in the request body). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. **Two agent-run resolution sweeps done (07-07, 07-08) → live: resolved 35, fired 9, Brier 0.157, calibration buckets populated** (was honest-empty before 07-07) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)`, **timezone `Asia/Tokyo`** (verified 2026-07-25 — the scheduler is NOT in UTC, same as `newsPostDevTo` #7). Fires at :20 past JST hours 0/3/6/… = **15/18/21/00/… UTC**.\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `LINKEDIN_POSTS_TABLE` (=`NewsProject-linkedin-posts`; the deployed var is **not** `SOCIAL_POSTS_TABLE` — code reads `SOCIAL_POSTS_TABLE || LINKEDIN_POSTS_TABLE` so the fallback wins), `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPairIntelligenceWeekly` — **ENABLED** — `cron(0 8 ? * MON *)` (Mondays 08:00 UTC; **corrected 2026-07-25 — was documented as manual-only**). Also invocable manually with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for the default 10 pairs.\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** the source default still reads Grok, but the **deployed** function is on DeepSeek (`GROK_MODEL=deepseek-v4-flash` since the 2026-07-26 retirement migration — was `deepseek-chat`; `GROK_API_URL=https://api.deepseek.com/chat/completions`; request body sends `thinking:disabled`). Runs weekly via `TriggerPairIntelligenceWeekly` (see trigger above) plus ad-hoc manual invokes.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` — DEPLOYED + SCHEDULED (proposal mode; `DRY_RUN=false`)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerBreakingAlert` — **ENABLED** — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis).\n\n> ⚠️ **Doc corrected 2026-07-25 (agent-review staleness audit):** this function was long documented as \"BUILT — NOT DEPLOYED (dry-run)\". Live AWS shows it is **deployed** (Timeout 120s / 256MB), driven by the **ENABLED** rule above, wired to the `GlobalPerspectiveBreakingAlerts` table, with **`DRY_RUN=false`**. What is still true is the **human-in-the-loop / never-auto-sends** design: even at `DRY_RUN=false` the code only writes a `status:'proposed'` row and logs \"awaiting human confirmation before send\" — the actual email send lives in `newsEmailSender` breaking mode (#29), gated on a human `status:'confirmed'`. So \"proposes automatically, sends only after human confirm,\" **not** \"dry-run/un-deployed.\"\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` runs live on its 4-hourly schedule writing structured `status:'proposed'` records; legacy confirmed records render via the saved-email-text fallback in `get_alert`.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (deployed, verified 2026-07-25):** `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE` (=`SummarizeAndPredict`), `BREAKING_ALERTS_TABLE` (=`GlobalPerspectiveBreakingAlerts`), `RESEND_API_KEY` (set), `SITE_URL`, `DRY_RUN` (=`false`), plus `ALERT_EMAIL_FROM` (=`onboarding@resend.dev`) and `ALERT_EMAIL_TO` (operator address). `SIGNIFICANCE_THRESHOLD`/`DEDUPE_DAYS` are **not** set → code defaults only. No Function URL (this is a scheduled Lambda, not URL-invoked).\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars (deployed, verified 2026-07-25):** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `USERS_DDB_TABLE` (=`GlobalPerspectiveUserTable`, added for the follow-country tier check), `FIREBASE_PROJECT_ID`. (`BREAKING_ALERTS_TABLE` and `SITE_URL` are **not** set in the deployed env — code falls back to literals.) **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered. Country-note fields: `changeScore`/`changeLevel`/`changeDimensions{axis:{from,to,delta}}` (what moved) + `priorHeadline`/`currentHeadline` + `triggerEvent`/`whyChanged`/`noSingleDriver`, **and (scoring-v2 Phase D-email, 2026-07-08) the current standing `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel`** for the email's Level-B scorecard (the vector was already loaded for `changeDimensions`; not backfilled — new notes only).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**⚠️ DEPLOYED ≠ REPO (2026-07-10):** prod still runs the **pre-credits 06-16 code** (gate = `ANALYZE_DAILY_CAP=50`) with the **P1 professional-structure SYSTEM_PROMPT patched in** (2026-07-10: downloaded the deployed zip, patched ONLY the prompt block, redeployed; `MAX_TOKENS` env 1600→2400 for the longer structured output). The server-pinned prompt is now mirrored **verbatim** from the frontend's `utils/analysisPrompt.js` — it had silently drifted (missing all 06-13 upgrades, so member runs got a weaker analyst than BYOK). **Re-synced again 2026-07-10 (`4193610`)** with the **gp-struct visual-block reliability fix** (embedded JSON template + emission mandate — raised valid-block emission on the DeepSeek member path from 1/4 to 8/8, so the ScenarioBars/etc. now actually render for members); the lens-task tail reminders reach the model via the frontend-built `body.user` (the Lambda only holds `SYSTEM_PROMPT`), so syncing that array was sufficient. Same patched-zip method: `FE===BE` verified byte-for-byte (5278 chars). **Never zip the repo `src/index.js` to prod until `PROD_CREDITS_NEXT_STEPS.md` runs** — it carries the parked credits code, and deploying it without the env-ordering steps can mis-grant orders. **Model switch + timeout fix (2026-07-10, prod-config only):** the member-path model was switched `deepseek-chat` (V4 Flash) → **`deepseek-v4-pro`** (env `GROK_MODEL`, the bake-off's stronger analyst), with a required code patch (same patched-deployed-zip method) sending `thinking:{type:'disabled'}` in `runDeepSeek` — v4-pro defaults to thinking mode, which without this burns the whole `MAX_TOKENS` budget on `reasoning_content` and returns **empty output** (`len=0, finish_reason=length`, proven live before the fix; code patch deployed before the env flip so there was no broken window). `ANALYZE_DAILY_CAP` raised 50→100 and `Timeout` raised 30s→120s (v4-pro takes ~40–60s; the old 30s timeout surfaced in-browser as a fake-CORS **\"Failed to fetch\" / \"No 'Access-Control-Allow-Origin' header\"** error — the Function-URL CORS config was correctly empty throughout, so this was NOT the dual-CORS trap: on a synchronous Function-URL Lambda, a timed-out invocation returns a bare platform error with no CORS headers, which reads as CORS but is actually a timeout).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live). **Per-axis view — scoring-v2 Phase D-email (2026-07-08, `SCORING_MODEL_V2_PLAN.md` §12):** `renderDriftEmail` renders a degradation ladder — `note.currentDimensions` → **Level B** compact scorecard (`HIGH · Humanitarian 82` + 4-axis row + `⚠ N/4 elevated`); else `changeDimensions` → **Level A** per-axis change pills; else the legacy scalar. No frontend-util import (reads the note's derived headline fields; breadth flag inline). Not retroactive — Level B only for notes written after the corrector deploy.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n### Prod infra NOT on `main` — Signal-API + impact-first ingest (documented 2026-07-25)\n\nFour Lambdas are **deployed and ENABLED in prod** but their **source lives only on the unmerged `signal-api` branch/worktree**, so they had no entry here. They pair with 4 undocumented DDB tables (below). Flagged by the 2026-07-25 staleness audit; source not on `main` means the deployed-bytes are the only reference — verify against live AWS before changing them. See [[project_signal_api_deployed]], [[project_impact_first_redesign]], `signal-api` branch.\n\n| Function | Trigger (ENABLED) | Config | Role (from deployed env + tables) |\n|---|---|---|---|\n| **`newsSignals`** | `TriggerSignalsBuild` `cron(0 10 * * ? *)` + a public **Function URL** (AuthType NONE, key-gated in code) | 300s / 512MB | Signal-API v1 builder — writes `GlobalPerspectiveSignals` (120d TTL) from `PredictionLog`/`SummarizeAndPredict`/`BreakingAlerts`; `API_KEYS_TABLE=GlobalPerspectiveApiKeys`, `FREE_DELAY_HOURS=24` (paid-tier freshness gate). |\n| **`newsImpactAudit`** | `TriggerImpactAudit` `cron(0 9 * * ? *)` | 120s / 256MB | Coverage dead-man's-switch — DeepSeek audit of whether high-impact events were MISSED; `IMPACT_AUDIT_TABLE`/`INGEST_CAPTURE_TABLE`, SNS→`GlobalPerspectiveAlerts` when misses ≥ `MISS_ALERT_THRESHOLD=2`. |\n| **`newsGdacsIngest`** | `TriggerGdacsIngest` `cron(0 */6 * * ? *)` | 120s / 256MB | GDACS disaster-alert ingest feed → `GlobalPerspectiveGdacsEvents`. |\n| **`newsGdeltConflict`** | `TriggerGdeltConflict` `cron(0 */6 * * ? *)` | 120s / 512MB | GDELT conflict-event ingest feed → `GlobalPerspectiveGdeltConflict`. |\n\n---\n\n### 30. `newsModelGuard` — model-retirement dead-man's-switch (LIVE 2026-07-28)\n**Path:** `amplify/backend/function/newsModelGuard/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerModelGuard` — **ENABLED** — `cron(0 12 * * ? *)` (daily 12:00 UTC). **No LLM** (one cheap authenticated `GET /models`).\n\nThe passive guard against a repeat of the **2026-07-24 `deepseek-chat` retirement** (which became a 36h silent outage because nothing was watching the deadline — see `BACKEND_DEEPSEEK_V4_MIGRATION_PLAN.md`). Daily it `ListFunctions` across the whole account, reads each function's env for model-ID vars (`GROK_MODEL`/`LLM_MODEL`/`MODEL`/`AUDIT_MODEL`/`JUDGE_MODEL`/`PPLX_MODEL`/`AI_MODEL`), and flags any **direct DeepSeek id** (`/^deepseek-/`; the OpenRouter `deepseek/…` namespaced form is skipped) that is either a **deprecated alias** (`deepseek-chat`/`deepseek-reasoner`) or **absent from DeepSeek's live `/models` list** (already retired/invalid). Findings → SNS `GlobalPerspectiveAlerts` with the proven fix recipe; **honest-empty** (silent when all clear). Skips `*-sandbox` functions + `IGNORE_FUNCTIONS` to avoid nagging on throwaway test envs. **Known gap:** env-scan only — a function hitting the retired model via a hardcoded code default (`process.env.X || 'deepseek-chat'`) is invisible here; those were separately hardened to `deepseek-v4-flash` defaults 2026-07-28.\n\n**Key env vars:** `DEEPSEEK_API_KEY` (copied from the fleet — auths the `/models` call), `DEEPSEEK_MODELS_URL` (=`https://api.deepseek.com/models`), `SNS_TOPIC_ARN` (=`GlobalPerspectiveAlerts`), optional `MODEL_VARS`/`DEPRECATED_ALIASES`/`IGNORE_FUNCTIONS`. **Role:** `newsModelGuard-role` (basic-exec logs + `lambda:ListFunctions`/`GetFunctionConfiguration` account-wide read + `sns:Publish` to the alerts topic only).\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch); **`newsModelGuard` (#30)** alerts when any Lambda is left on a deprecated/retired DeepSeek model (the model-retirement dead-man's-switch, born from the 2026-07-24 `deepseek-chat` outage). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d, in-scope — G6 drops pure sporting results from 2026-07-07) and gated-out triggers are recorded in `capture.dropped` with their `gate`; a scenario with no surviving dated trigger carries `scoreable:false`. `finalVerdict`/`confirmedBy:'agent-verified'` written by the Phase-2 agent loop (first run 2026-07-07). Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Populated live by the scheduled detector (`newsBreakingAlert` runs 4-hourly, `DRY_RUN=false`) — see [Lambda #21](#21-newsbreakingalert--deployed--scheduled-proposal-mode-dry_runfalse) + `BREAKING_ALERTS_PLAN.md`.\n\n### Signal-API + ingest tables (`signal-api` branch — live in prod, documented 2026-07-25)\nThese exist in prod DynamoDB but their producers' source is on the unmerged `signal-api` branch (see \"Prod infra NOT on `main`\" under the Lambda inventory). Key schemas confirmed via `describe-table` 2026-07-25:\n\n| Table | Key | Written by | Contents |\n|-------|-----|-----------|----------|\n| `GlobalPerspectiveSignals` | `signal_id` | newsSignals | Signal-API v1 records (120d TTL) |\n| `GlobalPerspectiveApiKeys` | `keyHash` | mint-key.mjs / newsSignals | Signal-API access keys |\n| `GlobalPerspectiveGdacsEvents` | (see describe-table) | newsGdacsIngest | GDACS disaster-alert ingest |\n| `GlobalPerspectiveGdeltConflict` | (see describe-table) | newsGdeltConflict | GDELT conflict-event ingest |\n| `GlobalPerspectiveImpactAudit` | (see describe-table) | newsImpactAudit | Coverage-audit results |\n| `GlobalPerspectiveIngestCapture` | (see describe-table) | ingest feeds | Raw high-impact event capture (audited by newsImpactAudit) |\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G6** (`src/lib.js`; G6 = sporting-result scope, added 2026-07-07) → structured, forward-dated, in-scope, `methodologyVersion:1` triggers; malformed/out-of-scope ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4, runbook `predictions/V1_RESOLUTION_RUNBOOK.md`): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface. **Two runs done (weekly cadence):** 07-07 scored 16 of 21 due triggers (5 deferred, window still open on run day); **07-08 scored all 21 due triggers** (incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers, cleared on recheck). Same-day-deadline learning: verifiers need a \"defer\" option when the event window hasn't closed (not yet folded into the runbook doc). Live totals: resolved 35, fired 9, Brier 0.157 (up from 0.084 after run 1 — run 2 added real high-stakes calls with genuine misses, the honest direction; the 60-80% bucket runs ~10pts hot but the curve stays monotonic).\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n5. **Feedback (Phase 5, DESIGNED 2026-07-08 — data-gated, NOT built)** — `CALIBRATION_DIGEST_PLAN.md`: a weekly `newsCalibrationDigest` Lambda will compact the resolved-trigger trace into one ~200-token `CALIBRATION#DIGEST` row (probability-bucket calibration + per-reference-class fire rates + deterministic bias lines); generation GetItems it and injects a `calibrationBlock` into `buildPredictionPrompt`, stamping `methodologyVersion:2` (the flat-65% v1 era stays as control group). Build gates: CP-1 manual measure at ~50 resolved; CP-2 infra at ~150 resolved. Honesty rule: feedback changes what the model says NEXT, never remaps stored/displayed probabilities. Digest rows must NOT carry `methodologyVersion` (the extract script and `prediction_track_record` filter on it).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-07-25)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. **EventBridge Rules run in UTC; several EventBridge *Scheduler* entries run in `Asia/Tokyo` — do NOT assume UTC for the Scheduler table** (the \"All times UTC\" claim was wrong pre-2026-07-25: 4 of 7 schedulers are JST — see the timezone column below).\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `TriggerModelGuard` | `cron(0 12 * * ? *)` | newsModelGuard (#30 — daily model-retirement guard; SNS-alerts on deprecated/retired DeepSeek model) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n| `TriggerPairIntelligenceWeekly` | `cron(0 8 ? * MON *)` | newsPairIntelligence (#8 — Mondays 08:00 UTC; **NOT manual-only**, corrected 2026-07-25) |\n| `TriggerBreakingAlert` | `cron(15 */4 * * ? *)` | newsBreakingAlert (#21 — 4-hourly, ENABLED, `DRY_RUN=false`) |\n| `TriggerDriftCorrector` | `cron(20 7 * * ? *)` | newsDriftCorrector (#26 — 07:20 UTC daily) |\n| `TriggerWeeklyEmailSend` | `cron(0 14 ? * SUN *)` | newsEmailSender (#29 — weekly digest, ENABLED) |\n| `TriggerBreakingEmailSend` | `rate(15 minutes)` | newsEmailSender (#29 — breaking sends, ENABLED) |\n| `TriggerDriftEmailSend` | `cron(40 7 * * ? *)` | newsEmailSender (#29 — drift alerts, **DISABLED** by design) |\n| `TriggerFreshnessMonitor` | `cron(30 0/2 * * ? *)` | newsFreshnessMonitor (#18) |\n| `TriggerErrorDigest` | `cron(15 0/6 * * ? *)` | newsErrorDigest (#19) |\n| `TriggerSignalsBuild` | `cron(0 10 * * ? *)` | newsSignals (Signal-API — `signal-api` branch, deployed to prod; see \"Prod infra not on `main`\") |\n| `TriggerImpactAudit` | `cron(0 9 * * ? *)` | newsImpactAudit (impact-first — `signal-api` branch) |\n| `TriggerGdacsIngest` | `cron(0 */6 * * ? *)` | newsGdacsIngest (GDACS ingest — `signal-api` branch) |\n| `TriggerGdeltConflict` | `cron(0 */6 * * ? *)` | newsGdeltConflict (GDELT ingest — `signal-api` branch) |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` **TZ Asia/Tokyo** | newsPostLinkedin (:20 past JST hours 0/3/6/… = **15/18/21/00/… UTC** — NOT UTC, corrected 2026-07-25) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` **TZ Asia/Tokyo** | linkedInAutoPost (07:30 / 19:30 **JST** = **22:30 / 10:30 UTC** — corrected 2026-07-25) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` **TZ Asia/Tokyo** | newsCountryFactsUpdater (05:00 **JST** = **20:00 UTC** prior day — corrected 2026-07-25) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual / user-triggered only)\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST / Function URL\n- (`newsPairIntelligence` was listed here as \"manual only\" — **corrected 2026-07-25**: it is now on the ENABLED weekly rule `TriggerPairIntelligenceWeekly`, Mondays 08:00 UTC.)\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~67 component files (in `src/components/` + 19 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then **three** stacked layer lenses: **Today's pulse** (24h fresh-news rings), **Connections** (bilateral arcs from `usePairAnalyses`), **Editorial** (top-5-by-signal numbered pins). Base country fills + two-tier signal markers are always drawn from `useCountrySignal` (a **client-side 30-day z-score** over `useWeeklyArchive`, bucketed H ≥ 1.5σ · E ≥ 0.5σ, forced quiet under 2 articles — coverage-volume anomaly, deliberately distinct from the backend `riskScore`/tier shown in the detail panel). A 4th **Economy** lens (disruption-exposure rings) was **removed 2026-07-29**: its `econByISO` lookup missed `nameToISO`'s lowercased keys, so it had never rendered a ring, and an always-empty exposure layer reads as \"no exposure anywhere\" ([[feedback_no_misinformation_fallback]]). Disruption data still reaches this page via the lede band and the detail panel's Economic Disruption section. Rendering is **imperative d3** — `drawMap()` wipes and re-appends the whole SVG on a 240ms-debounced effect, so live data reaches it through state-mirrored refs; geometry is `world-atlas@2/countries-110m` fetched from jsDelivr **at runtime**, not bundled |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. **Corrections ledger now also renders `FollowButton` on country-scope rows** (2026-07-08, `FOLLOW_SURFACING_PLAN.md`, `b455ee1`) — sibling of the name link; `FollowButton` was CountryPage-only until now. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). **07-09:** accepts `?stories=<id>[,…]` preselect (seeds once on topics-load; unknown ids dropped) — deep-linked from Home per-story \"Analyze →\", ThreadPage \"Analyze this arc\", `/economy`. **07-10:** output is ICD-203-structured (Key Judgments + yardstick + confidence axis + indicators table); the trailing `gp-struct` JSON block is extracted by `utils/analysisStruct.js` (validator/Markdown get stripped prose) and rendered as `atoms/AnalysisVisuals.jsx` **ScenarioBars/IndicatorMatrix/RippleTable** (struct null → prose-only honest fallback). **07-10 (later, `4193610`):** prompt fix raised DeepSeek's `gp-struct` emission 1/4→8/8 so the visuals render reliably; **Alibaba Qwen** added to the provider list with a workspace base-URL override for `sk-ws-` keys. |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n34 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter / **Alibaba Qwen**) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Qwen (added 2026-07-10)** — flagship `qwen3.7-max` (+`qwen3-max`/`qwen3.7-plus`), `enable_thinking:false` (same non-streaming reason as DeepSeek V4); its keyHint/comment flag that Alibaba **workspace-scoped `sk-ws-…` keys** 401 the default `dashscope-intl` host and need the WorkspaceId-specific `…maas.aliyuncs.com` host → handled by `allowBaseUrlOverride` + the optional \"Endpoint URL\" field in `ProviderModal` (persisted in `byok.js`, applied via `runChat({baseUrl})`→`baseUrl||provider.baseUrl`; every other provider unchanged when blank). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Professional structure (2026-07-10, `ANALYSIS_PRO_STRUCTURE_PLAN.md`):** prompts upgraded to ICD-203/desk-note packaging — Key Judgments box (BLUF, each judgment = yardstick probability term + separate justified confidence), the ICD-203 probability yardstick verbatim, an `| Indicator to watch | Confirms | Kills |` table on the scenario lens, and a `gp-struct` machine-readable block → ScenarioBars/IndicatorMatrix/RippleTable visuals (a chart can only show numbers the prose already states; every rule carries an omit-when-unsupported escape hatch — the 06-13 structure-pressure-induces-fabrication lesson). Server prompt (`newsAnalyze`) synced verbatim; guided maxTokens 1600→2400.\n- **Surfacing (2026-07-09, `ANALYZE_SURFACING_PLAN.md`):** was ONE nav link → now Home per-story \"Analyze →\" pill + hero line, ThreadPage \"Analyze this arc →\", `/economy` cross-link, all deep-linking via `?stories=` preselect.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — **only `newsPolarBilling` and `newsAnalyze` own CORS in code** (`corsHeaders` + an OPTIONS 204 handler); their Function-URL CORS config **must stay empty**, or AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser; bit us at the Polar checkout 2026-06-30). **Corrected 2026-07-25 (staleness audit):** `newsSavedItems` and `newsRecommend` do the **opposite** — their code emits **no** ACAO and they rely on a **populated** Function-URL CORS config. Do **not** clear their Function-URL CORS (that would remove all CORS and break them in-browser). Rule of thumb before touching any function's Function-URL CORS: grep the source for `Access-Control-Allow-Origin` first — code emits it ⇒ Function-URL CORS empty; code doesn't ⇒ Function-URL CORS populated.\n"}, "1de5d71462b54f4967ac28e998081b59637bfccf": {"t": 1785765785.8827662, "n": 179693, "h": "f530ba6c6a92b2e8", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-27 (refresh: **DEEPSEEK `deepseek-chat` RETIREMENT — FLEET MIGRATED TO V4**. `deepseek-chat` was retired by DeepSeek **2026-07-24**, silently breaking all 11 DeepSeek Lambdas for ~36h (most swallow the LLM error and exit 200, so CloudWatch showed 0 errors — only the freshness monitor's stale-content alert surfaced it). **Fixed 2026-07-26:** added `thinking: { type: 'disabled' }` to each function's DeepSeek request body (V4 defaults to thinking-mode → burns `max_tokens` on invisible `reasoning_content` → truncated/empty output — proven live: v4-flash without the patch clustered only 1 topic at `finish_reason=length`), then flipped `GROK_MODEL` per an intent-based split — **v4-pro** for the 3 open-analytical functions (`newsCountryIntelligence`, `newsSystemsAnalysis`, `newsEconomicImpact`), **v4-flash** for the mechanical/constrained/judge-protected/high-volume rest (`newsInvokeGemini`, `NewsProjectInvokeAgentLambda`, `newsDriftCorrector`, `newsPredictionResolver`, `newsWeeklyBrief`, `newsWeeklyMarkets`-producer, `newsPairIntelligence`, `newsImpactAudit`). Pipeline verified live end-to-end (13 topics → 39 AI items → swap). The `thinking` patch is now **ported into the repo `src/index.js` of all 10 on-main functions** (2026-07-27) so a future zip-from-repo deploy won't regress it. ⚠️ Still on the dead model (separate PPA/property project, same account): `PPAcomputeWardProfiles-dev`, `PPAfetchMarketNews-dev` — patch prepared, deploy pending operator OK. Prior: **AGENT-REVIEW STALENESS AUDIT** — a 5-auditor parallel sweep verified the whole doc against live AWS (`AGENT_REVIEW_METHOD.md`). Fixes landed: (1) **`newsBreakingAlert` #21 is DEPLOYED + scheduled + `DRY_RUN=false`**, not \"BUILT — NOT DEPLOYED\" (still human-confirm-before-send); (2) **4 of 7 EventBridge *Scheduler* entries run `Asia/Tokyo`, not UTC** — `InvokeLinkedIn`/`LinkedinThreadsDaily`/`Fact` corrected (`InvokeDev` was already right); the \"All times UTC\" header was wrong; (3) **`newsPairIntelligence` #8 is on the ENABLED weekly rule `TriggerPairIntelligenceWeekly`**, not manual-only; (4) **Common Mistakes #7 CORS list corrected** — only `newsPolarBilling`+`newsAnalyze` own CORS in code (Function-URL CORS empty); `newsSavedItems`+`newsRecommend` do the opposite (populated Function-URL CORS, no ACAO in code) — clearing theirs would break them; (5) **a Signal-API + impact-first ingest subsystem** (`newsSignals`/`newsImpactAudit`/`newsGdacsIngest`/`newsGdeltConflict` + 6 DDB tables) is live in prod from the unmerged `signal-api` branch — now documented as \"Prod infra NOT on `main`\"; (6) stale counts: proxy actions ~27→~33, components ~64→67, atoms 17→19, hooks 32→34; `newsPostLinkedin` env `SOCIAL_POSTS_TABLE`→deployed `LINKEDIN_POSTS_TABLE`. Backend #10–20 + frontend routes/components/hooks mappings verified CLEAN. Note to reconcile: **CLAUDE.md's Dual-CORS bullet + memory `feedback_lambda_function_url_cors` still carry the old 4-function CORS list** (fix #4 above). Prior: **ANALYSIS STUDIO — VISUAL-BLOCK RELIABILITY FIX + QWEN PROVIDER** [[project_analysis_studio]]. 07-10 [`4193610`, `QWEN_AND_VISUAL_BLOCK_PLAN.md`]: a **qwen3.7-max vs deepseek-v4-pro bake-off** (same stories/lenses, shipped pipeline) exposed the real \"where's the graph?\" cause — DeepSeek (the member-path model) emitted the `gp-struct` block in only **1/4** runs (writes the scenario prose but skips the machine-readable block), so the 07-10 ScenarioBars/IndicatorMatrix/RippleTable rarely rendered for members; Qwen emitted 4/4. **Fix (prompt-only, `analysisPrompt.js`):** embedded a fully-filled JSON template in SYSTEM_PROMPT + a mandatory tail reminder inside each lens task (where the model finishes) + removed the easy \"omit the whole block\" exit for scenario/economic lenses → valid-block emission **1/4 → 8/8** live (`scratchpad/emit-test.mjs`); honesty intact — `validateStruct` still strips any figure not literally in the prose, so all 8 validated with zero fabrication. `newsAnalyze` Lambda re-synced via the **patched-deployed-zip** method (Node made deployed SYSTEM_PROMPT byte-equal to the frontend, `FE===BE` 5278 chars; deployed bytes verified: template present, omit-exit gone, `creditBalance`=0, `MAX_TOKENS`=2400; lens-task tail reminders ride in via the frontend-built `body.user`). **Bake-off verdict:** DeepSeek stays the member-path default (tighter/MECE probability bands, deeper prose, always states confidence); **Qwen3.7-max added as a faster (~36%) BYOK option** — but Alibaba's international keys are **WORKSPACE-scoped (`sk-ws-…`)** and 401 the generic `dashscope-intl` host, needing `https://<WorkspaceId>.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1` (Tokyo; verified with `ws-gl08109galyxzzh3`), so a per-provider **base-URL override** was added (`allowBaseUrlOverride` on qwen; `runChat`/`runOpenAICompat` take optional `baseUrl`→`baseUrl||provider.baseUrl`; ProviderModal optional \"Endpoint URL\" field; `byok.js` persists it back-compat) — live-tested end-to-end through real `runChat` (override→200, default→401). Separately (same day, prod-config only): the member path itself was switched to **`deepseek-v4-pro`** (`ANALYZE_DAILY_CAP` 50→100, `Timeout` 30s→120s), which required patching `runDeepSeek` to send `thinking:{type:'disabled'}` — v4-pro's default thinking mode otherwise burns the whole `MAX_TOKENS` budget and returns empty output — and surfaced a lesson that a timed-out synchronous Function-URL Lambda returns no-ACAO, which looks like CORS but isn't. Prior: **ANALYSIS STUDIO SURFACED + PROFESSIONAL STRUCTURE**. 07-09 [`8cf2b29`, `ANALYZE_SURFACING_PLAN.md`]: `/analyze` was discoverable from ONE nav link (~2 runs/30d = discovery gap, not quality — engine live-tested 0/3 fabrication, and a manual web truth-check found every auditor flag was a FALSE POSITIVE vs ground truth); surfaced via a per-story **\"Analyze →\"** pill on Home (4th control beside Summary/Predict/Trace), a Home hero value-prop line, ThreadPage **\"Analyze this arc →\"** (deep-links `thread.entries[0].topicId`), an `/economy` cross-link, and a new **`?stories=<id>[,…]` preselect** in `AnalysisStudio.jsx` (seeds once on topics-load, unknown ids silently dropped). 07-10 [`c429931`, `ANALYSIS_PRO_STRUCTURE_PLAN.md`]: output upgraded to ICD-203/desk-note packaging — **Key Judgments box** (BLUF bullets), the **ICD-203 probability yardstick** verbatim (\"very likely (80–95%)\"), **confidence as a separate justified axis**, a consolidated **`| Indicator to watch | Confirms | Kills |` table** on the scenario lens, and a fenced **`gp-struct` JSON block** (a machine-readable INDEX of the prose — never new content) parsed by new `utils/analysisStruct.js` (truncation-safe; drops scenarios whose probability digits aren't literally in the prose) → rendered by `atoms/AnalysisVisuals.jsx` (**ScenarioBars/IndicatorMatrix/RippleTable**, pure CSS). Validator + Markdown always receive the STRIPPED prose. Review caught 4 pre-deploy bugs: `newsAnalyze`'s server-pinned prompt had **DRIFTED** (missing all 06-13 upgrades — members got a weaker analyst than BYOK; now synced verbatim), gp-struct truncation at 1600 tokens (guided maxTokens→2400, Studio+eval+Lambda env), and two validator false-positives from the new structure (comparison-operator thresholds `>50%`; scenario-heading ranges — estimative look-behind 32→48; goldens added). **⚠️ prod `newsAnalyze` deploy = PROMPT-PATCH of the downloaded DEPLOYED (pre-credits 06-16) zip — the repo file carries the PARKED credits code and must NOT reach prod until `PROD_CREDITS_NEXT_STEPS.md` runs.** Prior: **SECOND PREDICTION RESOLUTION RUN + DRIFT-EMAIL PER-AXIS VIEW + FOLLOWBUTTON ON THE LEDGER** [[project_prediction_methodology_v1]] [[project_scoring_model_v2]]. The 07-08 agent-run resolution sweep scored **all 21 due triggers** [incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers from the 07-07 run, cleared on recheck]. **`/track-record` now shows resolved 35 (was 15), fired 9 (was 4), Brier 0.157 (was 0.084)**; calibration buckets `0-20%`→stated ~17%/fired 9% (n11) · `20-40%`→stated ~20%/fired 9% (n11) · `60-80%`→stated ~65%/fired 54% (n13). Brier rose on purpose: run-1 was flattered by easy low-probability tails; run-2 added real high-stakes calls with genuine misses — the honest direction. The 65% bucket runs ~10pts hot (54% actual) but the curve is monotonic (9%→9%→54%). Cadence confirmed weekly agent-in-session. Prior (07-08, both already on main): **drift-alert email → per-axis view** [`0ec494f`, `SCORING_MODEL_V2_PLAN.md` §12] — `newsDriftCorrector` now embeds `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel` onto each new `DRIFT#`/`DRIFTLOG#` country note; `renderDriftEmail.js` renders a degradation ladder (currentDimensions → Level B 4-axis scorecard `HIGH · Humanitarian 82` + `⚠ N/4 elevated` → changeDimensions → Level A per-axis pills → legacy scalar); not retroactive. **FollowButton surfaced on the `/track-record` corrections ledger** [`b455ee1`, `FOLLOW_SURFACING_PLAN.md`] — the member country-follow control (previously CountryPage-only) now also renders as a sibling of the name link on each **country-scope** ledger row, the highest-intent \"get the next change emailed\" moment; management still lives at `/account?tab=notifications`. Deferred surfaces: CountryWhatChanged band (redundant), ThreadPage (multi-country ambiguity), countries-index (nesting/grid). Prior: **FIRST PREDICTION RESOLUTION RUN DONE + G6 SPORTS-SCOPE GATE LIVE** [[project_prediction_methodology_v1]]. The accountability record is no longer empty: the first agent-run resolution loop (`predictions/V1_RESOLUTION_RUNBOOK.md`) scored **16 of 21 due triggers** — 3 parallel blind web-grounded verifiers → 2nd independent blind pass on every `fired` → operator ~10% spot-check → dry-run → `resolve-v1-write.js --commit`. **`/track-record` now shows Brier 0.084**, resolved 15 (fired 4 · not_fired 11) + 1 unclear, calibration buckets 0-20%→0% (n6) · 20-40%→0% (n4) · 60-80%→80% (n5) [caveat: n tiny, 11/15 were low-p tails correctly not firing = easy points]; 5 triggers deferred (window still open on run day — Le Pen ruling ×3, INDOPACOM, Andersen). Deadlines arrived earlier than the ~07-11 estimate (short-horizon triggers). **New capture gate G6** [`NewsProjectInvokeAgentLambda/src/lib.js`, deployed to `-dev` + deployed-bytes verified; source `91e069e` + deploy `73e490d`]: drops **pure sporting-result** triggers — a match/tournament outcome or on-field participation carries no geopolitical/economic/institutional signal (the remit). Rule = drop only when BOTH a sporting-competition **context** regex AND a **result/participation** regex match, so sport-**adjacent** governance triggers survive (no result verb: \"Belgium files a FIFA protest\", \"CAS grants an interim measure\" pass). **Corpus-validated: across all 1038 live v1 triggers G6 drops exactly the 2 Trump–FIFA/Balogun box-score triggers, 0 false positives**; `node --test` 21/21. NOT retroactive (already-published sport triggers stay scored); scope decision was fix-forward at the trigger layer, NOT an ingest ban. Methodology disclosure live on `/track-record`. Cadence = weekly agent-in-session. Prior: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- **DeepSeek V4 (post-2026-07-26 migration — `deepseek-chat` is retired; all send `thinking:{type:'disabled'}`):**\n - **`deepseek-v4-pro`** (deeper reasoning; the open-analytical reads): `newsCountryIntelligence`, `newsSystemsAnalysis`, `newsEconomicImpact`\n - **`deepseek-v4-flash`** ($0.14/M in · $0.28/M out; mechanical/constrained/judge-protected/high-volume): `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsDriftCorrector`, `newsPredictionResolver`, `newsWeeklyBrief`, `newsWeeklyMarkets` (producer call), `newsPairIntelligence`, `newsImpactAudit`\n - `newsPostDevTo` uses **OpenRouter** (`deepseek/deepseek-v4-flash:free`), not the direct DeepSeek API — see #7\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek v4-flash** (migrated 2026-07-26 from the retired `deepseek-chat`; `api.deepseek.com`, `thinking:disabled`). On the weekly rule `TriggerPairIntelligenceWeekly` + ad-hoc manual invokes.\n- `newsAnalyze` → **DeepSeek v4-pro** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=100`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). **DEPLOYED + scheduled** (`TriggerBreakingAlert` 4-hourly, `DRY_RUN=false`; proposes only, human confirms before send) — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~33 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek v4-flash** (migrated 2026-07-26 from the retired `deepseek-chat`; `thinking:disabled`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-v4-flash` in production (was `deepseek-chat` until the 2026-07-26 retirement migration). Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check · **G6 capture-scope — drops pure sporting results, added 2026-07-07**) before it can enter the immutable log — a malformed or out-of-scope trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. G6 fires only when a trigger has BOTH a sporting-competition context AND a result/participation verb (so sport-adjacent governance triggers survive); corpus-validated 2/1038, 0 false positives. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (21/21, uses the real 2026-07-04-pilot defect triggers + the 2026-07-07 Balogun box-score triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-v4-flash` in production (was `deepseek-chat` until the 2026-07-26 retirement migration; `thinking:disabled` in the request body). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. **Two agent-run resolution sweeps done (07-07, 07-08) → live: resolved 35, fired 9, Brier 0.157, calibration buckets populated** (was honest-empty before 07-07) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)`, **timezone `Asia/Tokyo`** (verified 2026-07-25 — the scheduler is NOT in UTC, same as `newsPostDevTo` #7). Fires at :20 past JST hours 0/3/6/… = **15/18/21/00/… UTC**.\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `LINKEDIN_POSTS_TABLE` (=`NewsProject-linkedin-posts`; the deployed var is **not** `SOCIAL_POSTS_TABLE` — code reads `SOCIAL_POSTS_TABLE || LINKEDIN_POSTS_TABLE` so the fallback wins), `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPairIntelligenceWeekly` — **ENABLED** — `cron(0 8 ? * MON *)` (Mondays 08:00 UTC; **corrected 2026-07-25 — was documented as manual-only**). Also invocable manually with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for the default 10 pairs.\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** the source default still reads Grok, but the **deployed** function is on DeepSeek (`GROK_MODEL=deepseek-v4-flash` since the 2026-07-26 retirement migration — was `deepseek-chat`; `GROK_API_URL=https://api.deepseek.com/chat/completions`; request body sends `thinking:disabled`). Runs weekly via `TriggerPairIntelligenceWeekly` (see trigger above) plus ad-hoc manual invokes.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` — DEPLOYED + SCHEDULED (proposal mode; `DRY_RUN=false`)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerBreakingAlert` — **ENABLED** — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis).\n\n> ⚠️ **Doc corrected 2026-07-25 (agent-review staleness audit):** this function was long documented as \"BUILT — NOT DEPLOYED (dry-run)\". Live AWS shows it is **deployed** (Timeout 120s / 256MB), driven by the **ENABLED** rule above, wired to the `GlobalPerspectiveBreakingAlerts` table, with **`DRY_RUN=false`**. What is still true is the **human-in-the-loop / never-auto-sends** design: even at `DRY_RUN=false` the code only writes a `status:'proposed'` row and logs \"awaiting human confirmation before send\" — the actual email send lives in `newsEmailSender` breaking mode (#29), gated on a human `status:'confirmed'`. So \"proposes automatically, sends only after human confirm,\" **not** \"dry-run/un-deployed.\"\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` runs live on its 4-hourly schedule writing structured `status:'proposed'` records; legacy confirmed records render via the saved-email-text fallback in `get_alert`.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (deployed, verified 2026-07-25):** `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE` (=`SummarizeAndPredict`), `BREAKING_ALERTS_TABLE` (=`GlobalPerspectiveBreakingAlerts`), `RESEND_API_KEY` (set), `SITE_URL`, `DRY_RUN` (=`false`), plus `ALERT_EMAIL_FROM` (=`onboarding@resend.dev`) and `ALERT_EMAIL_TO` (operator address). `SIGNIFICANCE_THRESHOLD`/`DEDUPE_DAYS` are **not** set → code defaults only. No Function URL (this is a scheduled Lambda, not URL-invoked).\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars (deployed, verified 2026-07-25):** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `USERS_DDB_TABLE` (=`GlobalPerspectiveUserTable`, added for the follow-country tier check), `FIREBASE_PROJECT_ID`. (`BREAKING_ALERTS_TABLE` and `SITE_URL` are **not** set in the deployed env — code falls back to literals.) **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered. Country-note fields: `changeScore`/`changeLevel`/`changeDimensions{axis:{from,to,delta}}` (what moved) + `priorHeadline`/`currentHeadline` + `triggerEvent`/`whyChanged`/`noSingleDriver`, **and (scoring-v2 Phase D-email, 2026-07-08) the current standing `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel`** for the email's Level-B scorecard (the vector was already loaded for `changeDimensions`; not backfilled — new notes only).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**⚠️ DEPLOYED ≠ REPO (2026-07-10):** prod still runs the **pre-credits 06-16 code** (gate = `ANALYZE_DAILY_CAP=50`) with the **P1 professional-structure SYSTEM_PROMPT patched in** (2026-07-10: downloaded the deployed zip, patched ONLY the prompt block, redeployed; `MAX_TOKENS` env 1600→2400 for the longer structured output). The server-pinned prompt is now mirrored **verbatim** from the frontend's `utils/analysisPrompt.js` — it had silently drifted (missing all 06-13 upgrades, so member runs got a weaker analyst than BYOK). **Re-synced again 2026-07-10 (`4193610`)** with the **gp-struct visual-block reliability fix** (embedded JSON template + emission mandate — raised valid-block emission on the DeepSeek member path from 1/4 to 8/8, so the ScenarioBars/etc. now actually render for members); the lens-task tail reminders reach the model via the frontend-built `body.user` (the Lambda only holds `SYSTEM_PROMPT`), so syncing that array was sufficient. Same patched-zip method: `FE===BE` verified byte-for-byte (5278 chars). **Never zip the repo `src/index.js` to prod until `PROD_CREDITS_NEXT_STEPS.md` runs** — it carries the parked credits code, and deploying it without the env-ordering steps can mis-grant orders. **Model switch + timeout fix (2026-07-10, prod-config only):** the member-path model was switched `deepseek-chat` (V4 Flash) → **`deepseek-v4-pro`** (env `GROK_MODEL`, the bake-off's stronger analyst), with a required code patch (same patched-deployed-zip method) sending `thinking:{type:'disabled'}` in `runDeepSeek` — v4-pro defaults to thinking mode, which without this burns the whole `MAX_TOKENS` budget on `reasoning_content` and returns **empty output** (`len=0, finish_reason=length`, proven live before the fix; code patch deployed before the env flip so there was no broken window). `ANALYZE_DAILY_CAP` raised 50→100 and `Timeout` raised 30s→120s (v4-pro takes ~40–60s; the old 30s timeout surfaced in-browser as a fake-CORS **\"Failed to fetch\" / \"No 'Access-Control-Allow-Origin' header\"** error — the Function-URL CORS config was correctly empty throughout, so this was NOT the dual-CORS trap: on a synchronous Function-URL Lambda, a timed-out invocation returns a bare platform error with no CORS headers, which reads as CORS but is actually a timeout).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live). **Per-axis view — scoring-v2 Phase D-email (2026-07-08, `SCORING_MODEL_V2_PLAN.md` §12):** `renderDriftEmail` renders a degradation ladder — `note.currentDimensions` → **Level B** compact scorecard (`HIGH · Humanitarian 82` + 4-axis row + `⚠ N/4 elevated`); else `changeDimensions` → **Level A** per-axis change pills; else the legacy scalar. No frontend-util import (reads the note's derived headline fields; breadth flag inline). Not retroactive — Level B only for notes written after the corrector deploy.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n### Prod infra NOT on `main` — Signal-API + impact-first ingest (documented 2026-07-25)\n\nFour Lambdas are **deployed and ENABLED in prod** but their **source lives only on the unmerged `signal-api` branch/worktree**, so they had no entry here. They pair with 4 undocumented DDB tables (below). Flagged by the 2026-07-25 staleness audit; source not on `main` means the deployed-bytes are the only reference — verify against live AWS before changing them. See [[project_signal_api_deployed]], [[project_impact_first_redesign]], `signal-api` branch.\n\n| Function | Trigger (ENABLED) | Config | Role (from deployed env + tables) |\n|---|---|---|---|\n| **`newsSignals`** | `TriggerSignalsBuild` `cron(0 10 * * ? *)` + a public **Function URL** (AuthType NONE, key-gated in code) | 300s / 512MB | Signal-API v1 builder — writes `GlobalPerspectiveSignals` (120d TTL) from `PredictionLog`/`SummarizeAndPredict`/`BreakingAlerts`; `API_KEYS_TABLE=GlobalPerspectiveApiKeys`, `FREE_DELAY_HOURS=24` (paid-tier freshness gate). |\n| **`newsImpactAudit`** | `TriggerImpactAudit` `cron(0 9 * * ? *)` | 120s / 256MB | Coverage dead-man's-switch — DeepSeek audit of whether high-impact events were MISSED; `IMPACT_AUDIT_TABLE`/`INGEST_CAPTURE_TABLE`, SNS→`GlobalPerspectiveAlerts` when misses ≥ `MISS_ALERT_THRESHOLD=2`. |\n| **`newsGdacsIngest`** | `TriggerGdacsIngest` `cron(0 */6 * * ? *)` | 120s / 256MB | GDACS disaster-alert ingest feed → `GlobalPerspectiveGdacsEvents`. |\n| **`newsGdeltConflict`** | `TriggerGdeltConflict` `cron(0 */6 * * ? *)` | 120s / 512MB | GDELT conflict-event ingest feed → `GlobalPerspectiveGdeltConflict`. |\n\n---\n\n### 30. `newsModelGuard` — model-retirement dead-man's-switch (LIVE 2026-07-28)\n**Path:** `amplify/backend/function/newsModelGuard/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerModelGuard` — **ENABLED** — `cron(0 12 * * ? *)` (daily 12:00 UTC). **No LLM** (one cheap authenticated `GET /models`).\n\nThe passive guard against a repeat of the **2026-07-24 `deepseek-chat` retirement** (which became a 36h silent outage because nothing was watching the deadline — see `BACKEND_DEEPSEEK_V4_MIGRATION_PLAN.md`). Daily it `ListFunctions` across the whole account, reads each function's env for model-ID vars (`GROK_MODEL`/`LLM_MODEL`/`MODEL`/`AUDIT_MODEL`/`JUDGE_MODEL`/`PPLX_MODEL`/`AI_MODEL`), and flags any **direct DeepSeek id** (`/^deepseek-/`; the OpenRouter `deepseek/…` namespaced form is skipped) that is either a **deprecated alias** (`deepseek-chat`/`deepseek-reasoner`) or **absent from DeepSeek's live `/models` list** (already retired/invalid). Findings → SNS `GlobalPerspectiveAlerts` with the proven fix recipe; **honest-empty** (silent when all clear). Skips `*-sandbox` functions + `IGNORE_FUNCTIONS` to avoid nagging on throwaway test envs. **Known gap:** env-scan only — a function hitting the retired model via a hardcoded code default (`process.env.X || 'deepseek-chat'`) is invisible here; those were separately hardened to `deepseek-v4-flash` defaults 2026-07-28.\n\n**Key env vars:** `DEEPSEEK_API_KEY` (copied from the fleet — auths the `/models` call), `DEEPSEEK_MODELS_URL` (=`https://api.deepseek.com/models`), `SNS_TOPIC_ARN` (=`GlobalPerspectiveAlerts`), optional `MODEL_VARS`/`DEPRECATED_ALIASES`/`IGNORE_FUNCTIONS`. **Role:** `newsModelGuard-role` (basic-exec logs + `lambda:ListFunctions`/`GetFunctionConfiguration` account-wide read + `sns:Publish` to the alerts topic only).\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch); **`newsModelGuard` (#30)** alerts when any Lambda is left on a deprecated/retired DeepSeek model (the model-retirement dead-man's-switch, born from the 2026-07-24 `deepseek-chat` outage). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d, in-scope — G6 drops pure sporting results from 2026-07-07) and gated-out triggers are recorded in `capture.dropped` with their `gate`; a scenario with no surviving dated trigger carries `scoreable:false`. `finalVerdict`/`confirmedBy:'agent-verified'` written by the Phase-2 agent loop (first run 2026-07-07). Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Populated live by the scheduled detector (`newsBreakingAlert` runs 4-hourly, `DRY_RUN=false`) — see [Lambda #21](#21-newsbreakingalert--deployed--scheduled-proposal-mode-dry_runfalse) + `BREAKING_ALERTS_PLAN.md`.\n\n### Signal-API + ingest tables (`signal-api` branch — live in prod, documented 2026-07-25)\nThese exist in prod DynamoDB but their producers' source is on the unmerged `signal-api` branch (see \"Prod infra NOT on `main`\" under the Lambda inventory). Key schemas confirmed via `describe-table` 2026-07-25:\n\n| Table | Key | Written by | Contents |\n|-------|-----|-----------|----------|\n| `GlobalPerspectiveSignals` | `signal_id` | newsSignals | Signal-API v1 records (120d TTL) |\n| `GlobalPerspectiveApiKeys` | `keyHash` | mint-key.mjs / newsSignals | Signal-API access keys |\n| `GlobalPerspectiveGdacsEvents` | (see describe-table) | newsGdacsIngest | GDACS disaster-alert ingest |\n| `GlobalPerspectiveGdeltConflict` | (see describe-table) | newsGdeltConflict | GDELT conflict-event ingest |\n| `GlobalPerspectiveImpactAudit` | (see describe-table) | newsImpactAudit | Coverage-audit results |\n| `GlobalPerspectiveIngestCapture` | (see describe-table) | ingest feeds | Raw high-impact event capture (audited by newsImpactAudit) |\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G6** (`src/lib.js`; G6 = sporting-result scope, added 2026-07-07) → structured, forward-dated, in-scope, `methodologyVersion:1` triggers; malformed/out-of-scope ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4, runbook `predictions/V1_RESOLUTION_RUNBOOK.md`): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface. **Two runs done (weekly cadence):** 07-07 scored 16 of 21 due triggers (5 deferred, window still open on run day); **07-08 scored all 21 due triggers** (incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers, cleared on recheck). Same-day-deadline learning: verifiers need a \"defer\" option when the event window hasn't closed (not yet folded into the runbook doc). Live totals: resolved 35, fired 9, Brier 0.157 (up from 0.084 after run 1 — run 2 added real high-stakes calls with genuine misses, the honest direction; the 60-80% bucket runs ~10pts hot but the curve stays monotonic).\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n5. **Feedback (Phase 5, DESIGNED 2026-07-08 — data-gated, NOT built)** — `CALIBRATION_DIGEST_PLAN.md`: a weekly `newsCalibrationDigest` Lambda will compact the resolved-trigger trace into one ~200-token `CALIBRATION#DIGEST` row (probability-bucket calibration + per-reference-class fire rates + deterministic bias lines); generation GetItems it and injects a `calibrationBlock` into `buildPredictionPrompt`, stamping `methodologyVersion:2` (the flat-65% v1 era stays as control group). Build gates: CP-1 manual measure at ~50 resolved; CP-2 infra at ~150 resolved. Honesty rule: feedback changes what the model says NEXT, never remaps stored/displayed probabilities. Digest rows must NOT carry `methodologyVersion` (the extract script and `prediction_track_record` filter on it).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-07-25)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. **EventBridge Rules run in UTC; several EventBridge *Scheduler* entries run in `Asia/Tokyo` — do NOT assume UTC for the Scheduler table** (the \"All times UTC\" claim was wrong pre-2026-07-25: 4 of 7 schedulers are JST — see the timezone column below).\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `TriggerModelGuard` | `cron(0 12 * * ? *)` | newsModelGuard (#30 — daily model-retirement guard; SNS-alerts on deprecated/retired DeepSeek model) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n| `TriggerPairIntelligenceWeekly` | `cron(0 8 ? * MON *)` | newsPairIntelligence (#8 — Mondays 08:00 UTC; **NOT manual-only**, corrected 2026-07-25) |\n| `TriggerBreakingAlert` | `cron(15 */4 * * ? *)` | newsBreakingAlert (#21 — 4-hourly, ENABLED, `DRY_RUN=false`) |\n| `TriggerDriftCorrector` | `cron(20 7 * * ? *)` | newsDriftCorrector (#26 — 07:20 UTC daily) |\n| `TriggerWeeklyEmailSend` | `cron(0 14 ? * SUN *)` | newsEmailSender (#29 — weekly digest, ENABLED) |\n| `TriggerBreakingEmailSend` | `rate(15 minutes)` | newsEmailSender (#29 — breaking sends, ENABLED) |\n| `TriggerDriftEmailSend` | `cron(40 7 * * ? *)` | newsEmailSender (#29 — drift alerts, **DISABLED** by design) |\n| `TriggerFreshnessMonitor` | `cron(30 0/2 * * ? *)` | newsFreshnessMonitor (#18) |\n| `TriggerErrorDigest` | `cron(15 0/6 * * ? *)` | newsErrorDigest (#19) |\n| `TriggerSignalsBuild` | `cron(0 10 * * ? *)` | newsSignals (Signal-API — `signal-api` branch, deployed to prod; see \"Prod infra not on `main`\") |\n| `TriggerImpactAudit` | `cron(0 9 * * ? *)` | newsImpactAudit (impact-first — `signal-api` branch) |\n| `TriggerGdacsIngest` | `cron(0 */6 * * ? *)` | newsGdacsIngest (GDACS ingest — `signal-api` branch) |\n| `TriggerGdeltConflict` | `cron(0 */6 * * ? *)` | newsGdeltConflict (GDELT ingest — `signal-api` branch) |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` **TZ Asia/Tokyo** | newsPostLinkedin (:20 past JST hours 0/3/6/… = **15/18/21/00/… UTC** — NOT UTC, corrected 2026-07-25) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` **TZ Asia/Tokyo** | linkedInAutoPost (07:30 / 19:30 **JST** = **22:30 / 10:30 UTC** — corrected 2026-07-25) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` **TZ Asia/Tokyo** | newsCountryFactsUpdater (05:00 **JST** = **20:00 UTC** prior day — corrected 2026-07-25) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual / user-triggered only)\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST / Function URL\n- (`newsPairIntelligence` was listed here as \"manual only\" — **corrected 2026-07-25**: it is now on the ENABLED weekly rule `TriggerPairIntelligenceWeekly`, Mondays 08:00 UTC.)\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~67 component files (in `src/components/` + 19 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. **Corrections ledger now also renders `FollowButton` on country-scope rows** (2026-07-08, `FOLLOW_SURFACING_PLAN.md`, `b455ee1`) — sibling of the name link; `FollowButton` was CountryPage-only until now. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). **07-09:** accepts `?stories=<id>[,…]` preselect (seeds once on topics-load; unknown ids dropped) — deep-linked from Home per-story \"Analyze →\", ThreadPage \"Analyze this arc\", `/economy`. **07-10:** output is ICD-203-structured (Key Judgments + yardstick + confidence axis + indicators table); the trailing `gp-struct` JSON block is extracted by `utils/analysisStruct.js` (validator/Markdown get stripped prose) and rendered as `atoms/AnalysisVisuals.jsx` **ScenarioBars/IndicatorMatrix/RippleTable** (struct null → prose-only honest fallback). **07-10 (later, `4193610`):** prompt fix raised DeepSeek's `gp-struct` emission 1/4→8/8 so the visuals render reliably; **Alibaba Qwen** added to the provider list with a workspace base-URL override for `sk-ws-` keys. |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n34 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter / **Alibaba Qwen**) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Qwen (added 2026-07-10)** — flagship `qwen3.7-max` (+`qwen3-max`/`qwen3.7-plus`), `enable_thinking:false` (same non-streaming reason as DeepSeek V4); its keyHint/comment flag that Alibaba **workspace-scoped `sk-ws-…` keys** 401 the default `dashscope-intl` host and need the WorkspaceId-specific `…maas.aliyuncs.com` host → handled by `allowBaseUrlOverride` + the optional \"Endpoint URL\" field in `ProviderModal` (persisted in `byok.js`, applied via `runChat({baseUrl})`→`baseUrl||provider.baseUrl`; every other provider unchanged when blank). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Professional structure (2026-07-10, `ANALYSIS_PRO_STRUCTURE_PLAN.md`):** prompts upgraded to ICD-203/desk-note packaging — Key Judgments box (BLUF, each judgment = yardstick probability term + separate justified confidence), the ICD-203 probability yardstick verbatim, an `| Indicator to watch | Confirms | Kills |` table on the scenario lens, and a `gp-struct` machine-readable block → ScenarioBars/IndicatorMatrix/RippleTable visuals (a chart can only show numbers the prose already states; every rule carries an omit-when-unsupported escape hatch — the 06-13 structure-pressure-induces-fabrication lesson). Server prompt (`newsAnalyze`) synced verbatim; guided maxTokens 1600→2400.\n- **Surfacing (2026-07-09, `ANALYZE_SURFACING_PLAN.md`):** was ONE nav link → now Home per-story \"Analyze →\" pill + hero line, ThreadPage \"Analyze this arc →\", `/economy` cross-link, all deep-linking via `?stories=` preselect.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — **only `newsPolarBilling` and `newsAnalyze` own CORS in code** (`corsHeaders` + an OPTIONS 204 handler); their Function-URL CORS config **must stay empty**, or AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser; bit us at the Polar checkout 2026-06-30). **Corrected 2026-07-25 (staleness audit):** `newsSavedItems` and `newsRecommend` do the **opposite** — their code emits **no** ACAO and they rely on a **populated** Function-URL CORS config. Do **not** clear their Function-URL CORS (that would remove all CORS and break them in-browser). Rule of thumb before touching any function's Function-URL CORS: grep the source for `Access-Control-Allow-Origin` first — code emits it ⇒ Function-URL CORS empty; code doesn't ⇒ Function-URL CORS populated.\n"}, "8f6c82f7631166ecd2016b54dcc9bbafac2e9fbf": {"t": 1785765788.2025752, "n": 177539, "h": "e2f1a349406de9c3", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-27 (refresh: **DEEPSEEK `deepseek-chat` RETIREMENT — FLEET MIGRATED TO V4**. `deepseek-chat` was retired by DeepSeek **2026-07-24**, silently breaking all 11 DeepSeek Lambdas for ~36h (most swallow the LLM error and exit 200, so CloudWatch showed 0 errors — only the freshness monitor's stale-content alert surfaced it). **Fixed 2026-07-26:** added `thinking: { type: 'disabled' }` to each function's DeepSeek request body (V4 defaults to thinking-mode → burns `max_tokens` on invisible `reasoning_content` → truncated/empty output — proven live: v4-flash without the patch clustered only 1 topic at `finish_reason=length`), then flipped `GROK_MODEL` per an intent-based split — **v4-pro** for the 3 open-analytical functions (`newsCountryIntelligence`, `newsSystemsAnalysis`, `newsEconomicImpact`), **v4-flash** for the mechanical/constrained/judge-protected/high-volume rest (`newsInvokeGemini`, `NewsProjectInvokeAgentLambda`, `newsDriftCorrector`, `newsPredictionResolver`, `newsWeeklyBrief`, `newsWeeklyMarkets`-producer, `newsPairIntelligence`, `newsImpactAudit`). Pipeline verified live end-to-end (13 topics → 39 AI items → swap). The `thinking` patch is now **ported into the repo `src/index.js` of all 10 on-main functions** (2026-07-27) so a future zip-from-repo deploy won't regress it. ⚠️ Still on the dead model (separate PPA/property project, same account): `PPAcomputeWardProfiles-dev`, `PPAfetchMarketNews-dev` — patch prepared, deploy pending operator OK. Prior: **AGENT-REVIEW STALENESS AUDIT** — a 5-auditor parallel sweep verified the whole doc against live AWS (`AGENT_REVIEW_METHOD.md`). Fixes landed: (1) **`newsBreakingAlert` #21 is DEPLOYED + scheduled + `DRY_RUN=false`**, not \"BUILT — NOT DEPLOYED\" (still human-confirm-before-send); (2) **4 of 7 EventBridge *Scheduler* entries run `Asia/Tokyo`, not UTC** — `InvokeLinkedIn`/`LinkedinThreadsDaily`/`Fact` corrected (`InvokeDev` was already right); the \"All times UTC\" header was wrong; (3) **`newsPairIntelligence` #8 is on the ENABLED weekly rule `TriggerPairIntelligenceWeekly`**, not manual-only; (4) **Common Mistakes #7 CORS list corrected** — only `newsPolarBilling`+`newsAnalyze` own CORS in code (Function-URL CORS empty); `newsSavedItems`+`newsRecommend` do the opposite (populated Function-URL CORS, no ACAO in code) — clearing theirs would break them; (5) **a Signal-API + impact-first ingest subsystem** (`newsSignals`/`newsImpactAudit`/`newsGdacsIngest`/`newsGdeltConflict` + 6 DDB tables) is live in prod from the unmerged `signal-api` branch — now documented as \"Prod infra NOT on `main`\"; (6) stale counts: proxy actions ~27→~33, components ~64→67, atoms 17→19, hooks 32→34; `newsPostLinkedin` env `SOCIAL_POSTS_TABLE`→deployed `LINKEDIN_POSTS_TABLE`. Backend #10–20 + frontend routes/components/hooks mappings verified CLEAN. Note to reconcile: **CLAUDE.md's Dual-CORS bullet + memory `feedback_lambda_function_url_cors` still carry the old 4-function CORS list** (fix #4 above). Prior: **ANALYSIS STUDIO — VISUAL-BLOCK RELIABILITY FIX + QWEN PROVIDER** [[project_analysis_studio]]. 07-10 [`4193610`, `QWEN_AND_VISUAL_BLOCK_PLAN.md`]: a **qwen3.7-max vs deepseek-v4-pro bake-off** (same stories/lenses, shipped pipeline) exposed the real \"where's the graph?\" cause — DeepSeek (the member-path model) emitted the `gp-struct` block in only **1/4** runs (writes the scenario prose but skips the machine-readable block), so the 07-10 ScenarioBars/IndicatorMatrix/RippleTable rarely rendered for members; Qwen emitted 4/4. **Fix (prompt-only, `analysisPrompt.js`):** embedded a fully-filled JSON template in SYSTEM_PROMPT + a mandatory tail reminder inside each lens task (where the model finishes) + removed the easy \"omit the whole block\" exit for scenario/economic lenses → valid-block emission **1/4 → 8/8** live (`scratchpad/emit-test.mjs`); honesty intact — `validateStruct` still strips any figure not literally in the prose, so all 8 validated with zero fabrication. `newsAnalyze` Lambda re-synced via the **patched-deployed-zip** method (Node made deployed SYSTEM_PROMPT byte-equal to the frontend, `FE===BE` 5278 chars; deployed bytes verified: template present, omit-exit gone, `creditBalance`=0, `MAX_TOKENS`=2400; lens-task tail reminders ride in via the frontend-built `body.user`). **Bake-off verdict:** DeepSeek stays the member-path default (tighter/MECE probability bands, deeper prose, always states confidence); **Qwen3.7-max added as a faster (~36%) BYOK option** — but Alibaba's international keys are **WORKSPACE-scoped (`sk-ws-…`)** and 401 the generic `dashscope-intl` host, needing `https://<WorkspaceId>.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1` (Tokyo; verified with `ws-gl08109galyxzzh3`), so a per-provider **base-URL override** was added (`allowBaseUrlOverride` on qwen; `runChat`/`runOpenAICompat` take optional `baseUrl`→`baseUrl||provider.baseUrl`; ProviderModal optional \"Endpoint URL\" field; `byok.js` persists it back-compat) — live-tested end-to-end through real `runChat` (override→200, default→401). Separately (same day, prod-config only): the member path itself was switched to **`deepseek-v4-pro`** (`ANALYZE_DAILY_CAP` 50→100, `Timeout` 30s→120s), which required patching `runDeepSeek` to send `thinking:{type:'disabled'}` — v4-pro's default thinking mode otherwise burns the whole `MAX_TOKENS` budget and returns empty output — and surfaced a lesson that a timed-out synchronous Function-URL Lambda returns no-ACAO, which looks like CORS but isn't. Prior: **ANALYSIS STUDIO SURFACED + PROFESSIONAL STRUCTURE**. 07-09 [`8cf2b29`, `ANALYZE_SURFACING_PLAN.md`]: `/analyze` was discoverable from ONE nav link (~2 runs/30d = discovery gap, not quality — engine live-tested 0/3 fabrication, and a manual web truth-check found every auditor flag was a FALSE POSITIVE vs ground truth); surfaced via a per-story **\"Analyze →\"** pill on Home (4th control beside Summary/Predict/Trace), a Home hero value-prop line, ThreadPage **\"Analyze this arc →\"** (deep-links `thread.entries[0].topicId`), an `/economy` cross-link, and a new **`?stories=<id>[,…]` preselect** in `AnalysisStudio.jsx` (seeds once on topics-load, unknown ids silently dropped). 07-10 [`c429931`, `ANALYSIS_PRO_STRUCTURE_PLAN.md`]: output upgraded to ICD-203/desk-note packaging — **Key Judgments box** (BLUF bullets), the **ICD-203 probability yardstick** verbatim (\"very likely (80–95%)\"), **confidence as a separate justified axis**, a consolidated **`| Indicator to watch | Confirms | Kills |` table** on the scenario lens, and a fenced **`gp-struct` JSON block** (a machine-readable INDEX of the prose — never new content) parsed by new `utils/analysisStruct.js` (truncation-safe; drops scenarios whose probability digits aren't literally in the prose) → rendered by `atoms/AnalysisVisuals.jsx` (**ScenarioBars/IndicatorMatrix/RippleTable**, pure CSS). Validator + Markdown always receive the STRIPPED prose. Review caught 4 pre-deploy bugs: `newsAnalyze`'s server-pinned prompt had **DRIFTED** (missing all 06-13 upgrades — members got a weaker analyst than BYOK; now synced verbatim), gp-struct truncation at 1600 tokens (guided maxTokens→2400, Studio+eval+Lambda env), and two validator false-positives from the new structure (comparison-operator thresholds `>50%`; scenario-heading ranges — estimative look-behind 32→48; goldens added). **⚠️ prod `newsAnalyze` deploy = PROMPT-PATCH of the downloaded DEPLOYED (pre-credits 06-16) zip — the repo file carries the PARKED credits code and must NOT reach prod until `PROD_CREDITS_NEXT_STEPS.md` runs.** Prior: **SECOND PREDICTION RESOLUTION RUN + DRIFT-EMAIL PER-AXIS VIEW + FOLLOWBUTTON ON THE LEDGER** [[project_prediction_methodology_v1]] [[project_scoring_model_v2]]. The 07-08 agent-run resolution sweep scored **all 21 due triggers** [incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers from the 07-07 run, cleared on recheck]. **`/track-record` now shows resolved 35 (was 15), fired 9 (was 4), Brier 0.157 (was 0.084)**; calibration buckets `0-20%`→stated ~17%/fired 9% (n11) · `20-40%`→stated ~20%/fired 9% (n11) · `60-80%`→stated ~65%/fired 54% (n13). Brier rose on purpose: run-1 was flattered by easy low-probability tails; run-2 added real high-stakes calls with genuine misses — the honest direction. The 65% bucket runs ~10pts hot (54% actual) but the curve is monotonic (9%→9%→54%). Cadence confirmed weekly agent-in-session. Prior (07-08, both already on main): **drift-alert email → per-axis view** [`0ec494f`, `SCORING_MODEL_V2_PLAN.md` §12] — `newsDriftCorrector` now embeds `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel` onto each new `DRIFT#`/`DRIFTLOG#` country note; `renderDriftEmail.js` renders a degradation ladder (currentDimensions → Level B 4-axis scorecard `HIGH · Humanitarian 82` + `⚠ N/4 elevated` → changeDimensions → Level A per-axis pills → legacy scalar); not retroactive. **FollowButton surfaced on the `/track-record` corrections ledger** [`b455ee1`, `FOLLOW_SURFACING_PLAN.md`] — the member country-follow control (previously CountryPage-only) now also renders as a sibling of the name link on each **country-scope** ledger row, the highest-intent \"get the next change emailed\" moment; management still lives at `/account?tab=notifications`. Deferred surfaces: CountryWhatChanged band (redundant), ThreadPage (multi-country ambiguity), countries-index (nesting/grid). Prior: **FIRST PREDICTION RESOLUTION RUN DONE + G6 SPORTS-SCOPE GATE LIVE** [[project_prediction_methodology_v1]]. The accountability record is no longer empty: the first agent-run resolution loop (`predictions/V1_RESOLUTION_RUNBOOK.md`) scored **16 of 21 due triggers** — 3 parallel blind web-grounded verifiers → 2nd independent blind pass on every `fired` → operator ~10% spot-check → dry-run → `resolve-v1-write.js --commit`. **`/track-record` now shows Brier 0.084**, resolved 15 (fired 4 · not_fired 11) + 1 unclear, calibration buckets 0-20%→0% (n6) · 20-40%→0% (n4) · 60-80%→80% (n5) [caveat: n tiny, 11/15 were low-p tails correctly not firing = easy points]; 5 triggers deferred (window still open on run day — Le Pen ruling ×3, INDOPACOM, Andersen). Deadlines arrived earlier than the ~07-11 estimate (short-horizon triggers). **New capture gate G6** [`NewsProjectInvokeAgentLambda/src/lib.js`, deployed to `-dev` + deployed-bytes verified; source `91e069e` + deploy `73e490d`]: drops **pure sporting-result** triggers — a match/tournament outcome or on-field participation carries no geopolitical/economic/institutional signal (the remit). Rule = drop only when BOTH a sporting-competition **context** regex AND a **result/participation** regex match, so sport-**adjacent** governance triggers survive (no result verb: \"Belgium files a FIFA protest\", \"CAS grants an interim measure\" pass). **Corpus-validated: across all 1038 live v1 triggers G6 drops exactly the 2 Trump–FIFA/Balogun box-score triggers, 0 false positives**; `node --test` 21/21. NOT retroactive (already-published sport triggers stay scored); scope decision was fix-forward at the trigger layer, NOT an ingest ban. Methodology disclosure live on `/track-record`. Cadence = weekly agent-in-session. Prior: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- **DeepSeek V4 (post-2026-07-26 migration — `deepseek-chat` is retired; all send `thinking:{type:'disabled'}`):**\n - **`deepseek-v4-pro`** (deeper reasoning; the open-analytical reads): `newsCountryIntelligence`, `newsSystemsAnalysis`, `newsEconomicImpact`\n - **`deepseek-v4-flash`** ($0.14/M in · $0.28/M out; mechanical/constrained/judge-protected/high-volume): `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsDriftCorrector`, `newsPredictionResolver`, `newsWeeklyBrief`, `newsWeeklyMarkets` (producer call), `newsPairIntelligence`, `newsImpactAudit`\n - `newsPostDevTo` uses **OpenRouter** (`deepseek/deepseek-v4-flash:free`), not the direct DeepSeek API — see #7\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek v4-flash** (migrated 2026-07-26 from the retired `deepseek-chat`; `api.deepseek.com`, `thinking:disabled`). On the weekly rule `TriggerPairIntelligenceWeekly` + ad-hoc manual invokes.\n- `newsAnalyze` → **DeepSeek v4-pro** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=100`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). **DEPLOYED + scheduled** (`TriggerBreakingAlert` 4-hourly, `DRY_RUN=false`; proposes only, human confirms before send) — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~33 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek v4-flash** (migrated 2026-07-26 from the retired `deepseek-chat`; `thinking:disabled`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-v4-flash` in production (was `deepseek-chat` until the 2026-07-26 retirement migration). Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check · **G6 capture-scope — drops pure sporting results, added 2026-07-07**) before it can enter the immutable log — a malformed or out-of-scope trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. G6 fires only when a trigger has BOTH a sporting-competition context AND a result/participation verb (so sport-adjacent governance triggers survive); corpus-validated 2/1038, 0 false positives. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (21/21, uses the real 2026-07-04-pilot defect triggers + the 2026-07-07 Balogun box-score triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-v4-flash` in production (was `deepseek-chat` until the 2026-07-26 retirement migration; `thinking:disabled` in the request body). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. **Two agent-run resolution sweeps done (07-07, 07-08) → live: resolved 35, fired 9, Brier 0.157, calibration buckets populated** (was honest-empty before 07-07) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)`, **timezone `Asia/Tokyo`** (verified 2026-07-25 — the scheduler is NOT in UTC, same as `newsPostDevTo` #7). Fires at :20 past JST hours 0/3/6/… = **15/18/21/00/… UTC**.\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `LINKEDIN_POSTS_TABLE` (=`NewsProject-linkedin-posts`; the deployed var is **not** `SOCIAL_POSTS_TABLE` — code reads `SOCIAL_POSTS_TABLE || LINKEDIN_POSTS_TABLE` so the fallback wins), `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPairIntelligenceWeekly` — **ENABLED** — `cron(0 8 ? * MON *)` (Mondays 08:00 UTC; **corrected 2026-07-25 — was documented as manual-only**). Also invocable manually with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for the default 10 pairs.\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** the source default still reads Grok, but the **deployed** function is on DeepSeek (`GROK_MODEL=deepseek-v4-flash` since the 2026-07-26 retirement migration — was `deepseek-chat`; `GROK_API_URL=https://api.deepseek.com/chat/completions`; request body sends `thinking:disabled`). Runs weekly via `TriggerPairIntelligenceWeekly` (see trigger above) plus ad-hoc manual invokes.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` — DEPLOYED + SCHEDULED (proposal mode; `DRY_RUN=false`)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerBreakingAlert` — **ENABLED** — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis).\n\n> ⚠️ **Doc corrected 2026-07-25 (agent-review staleness audit):** this function was long documented as \"BUILT — NOT DEPLOYED (dry-run)\". Live AWS shows it is **deployed** (Timeout 120s / 256MB), driven by the **ENABLED** rule above, wired to the `GlobalPerspectiveBreakingAlerts` table, with **`DRY_RUN=false`**. What is still true is the **human-in-the-loop / never-auto-sends** design: even at `DRY_RUN=false` the code only writes a `status:'proposed'` row and logs \"awaiting human confirmation before send\" — the actual email send lives in `newsEmailSender` breaking mode (#29), gated on a human `status:'confirmed'`. So \"proposes automatically, sends only after human confirm,\" **not** \"dry-run/un-deployed.\"\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` runs live on its 4-hourly schedule writing structured `status:'proposed'` records; legacy confirmed records render via the saved-email-text fallback in `get_alert`.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (deployed, verified 2026-07-25):** `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE` (=`SummarizeAndPredict`), `BREAKING_ALERTS_TABLE` (=`GlobalPerspectiveBreakingAlerts`), `RESEND_API_KEY` (set), `SITE_URL`, `DRY_RUN` (=`false`), plus `ALERT_EMAIL_FROM` (=`onboarding@resend.dev`) and `ALERT_EMAIL_TO` (operator address). `SIGNIFICANCE_THRESHOLD`/`DEDUPE_DAYS` are **not** set → code defaults only. No Function URL (this is a scheduled Lambda, not URL-invoked).\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars (deployed, verified 2026-07-25):** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `USERS_DDB_TABLE` (=`GlobalPerspectiveUserTable`, added for the follow-country tier check), `FIREBASE_PROJECT_ID`. (`BREAKING_ALERTS_TABLE` and `SITE_URL` are **not** set in the deployed env — code falls back to literals.) **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered. Country-note fields: `changeScore`/`changeLevel`/`changeDimensions{axis:{from,to,delta}}` (what moved) + `priorHeadline`/`currentHeadline` + `triggerEvent`/`whyChanged`/`noSingleDriver`, **and (scoring-v2 Phase D-email, 2026-07-08) the current standing `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel`** for the email's Level-B scorecard (the vector was already loaded for `changeDimensions`; not backfilled — new notes only).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**⚠️ DEPLOYED ≠ REPO (2026-07-10):** prod still runs the **pre-credits 06-16 code** (gate = `ANALYZE_DAILY_CAP=50`) with the **P1 professional-structure SYSTEM_PROMPT patched in** (2026-07-10: downloaded the deployed zip, patched ONLY the prompt block, redeployed; `MAX_TOKENS` env 1600→2400 for the longer structured output). The server-pinned prompt is now mirrored **verbatim** from the frontend's `utils/analysisPrompt.js` — it had silently drifted (missing all 06-13 upgrades, so member runs got a weaker analyst than BYOK). **Re-synced again 2026-07-10 (`4193610`)** with the **gp-struct visual-block reliability fix** (embedded JSON template + emission mandate — raised valid-block emission on the DeepSeek member path from 1/4 to 8/8, so the ScenarioBars/etc. now actually render for members); the lens-task tail reminders reach the model via the frontend-built `body.user` (the Lambda only holds `SYSTEM_PROMPT`), so syncing that array was sufficient. Same patched-zip method: `FE===BE` verified byte-for-byte (5278 chars). **Never zip the repo `src/index.js` to prod until `PROD_CREDITS_NEXT_STEPS.md` runs** — it carries the parked credits code, and deploying it without the env-ordering steps can mis-grant orders. **Model switch + timeout fix (2026-07-10, prod-config only):** the member-path model was switched `deepseek-chat` (V4 Flash) → **`deepseek-v4-pro`** (env `GROK_MODEL`, the bake-off's stronger analyst), with a required code patch (same patched-deployed-zip method) sending `thinking:{type:'disabled'}` in `runDeepSeek` — v4-pro defaults to thinking mode, which without this burns the whole `MAX_TOKENS` budget on `reasoning_content` and returns **empty output** (`len=0, finish_reason=length`, proven live before the fix; code patch deployed before the env flip so there was no broken window). `ANALYZE_DAILY_CAP` raised 50→100 and `Timeout` raised 30s→120s (v4-pro takes ~40–60s; the old 30s timeout surfaced in-browser as a fake-CORS **\"Failed to fetch\" / \"No 'Access-Control-Allow-Origin' header\"** error — the Function-URL CORS config was correctly empty throughout, so this was NOT the dual-CORS trap: on a synchronous Function-URL Lambda, a timed-out invocation returns a bare platform error with no CORS headers, which reads as CORS but is actually a timeout).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live). **Per-axis view — scoring-v2 Phase D-email (2026-07-08, `SCORING_MODEL_V2_PLAN.md` §12):** `renderDriftEmail` renders a degradation ladder — `note.currentDimensions` → **Level B** compact scorecard (`HIGH · Humanitarian 82` + 4-axis row + `⚠ N/4 elevated`); else `changeDimensions` → **Level A** per-axis change pills; else the legacy scalar. No frontend-util import (reads the note's derived headline fields; breadth flag inline). Not retroactive — Level B only for notes written after the corrector deploy.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n### Prod infra NOT on `main` — Signal-API + impact-first ingest (documented 2026-07-25)\n\nFour Lambdas are **deployed and ENABLED in prod** but their **source lives only on the unmerged `signal-api` branch/worktree**, so they had no entry here. They pair with 4 undocumented DDB tables (below). Flagged by the 2026-07-25 staleness audit; source not on `main` means the deployed-bytes are the only reference — verify against live AWS before changing them. See [[project_signal_api_deployed]], [[project_impact_first_redesign]], `signal-api` branch.\n\n| Function | Trigger (ENABLED) | Config | Role (from deployed env + tables) |\n|---|---|---|---|\n| **`newsSignals`** | `TriggerSignalsBuild` `cron(0 10 * * ? *)` + a public **Function URL** (AuthType NONE, key-gated in code) | 300s / 512MB | Signal-API v1 builder — writes `GlobalPerspectiveSignals` (120d TTL) from `PredictionLog`/`SummarizeAndPredict`/`BreakingAlerts`; `API_KEYS_TABLE=GlobalPerspectiveApiKeys`, `FREE_DELAY_HOURS=24` (paid-tier freshness gate). |\n| **`newsImpactAudit`** | `TriggerImpactAudit` `cron(0 9 * * ? *)` | 120s / 256MB | Coverage dead-man's-switch — DeepSeek audit of whether high-impact events were MISSED; `IMPACT_AUDIT_TABLE`/`INGEST_CAPTURE_TABLE`, SNS→`GlobalPerspectiveAlerts` when misses ≥ `MISS_ALERT_THRESHOLD=2`. |\n| **`newsGdacsIngest`** | `TriggerGdacsIngest` `cron(0 */6 * * ? *)` | 120s / 256MB | GDACS disaster-alert ingest feed → `GlobalPerspectiveGdacsEvents`. |\n| **`newsGdeltConflict`** | `TriggerGdeltConflict` `cron(0 */6 * * ? *)` | 120s / 512MB | GDELT conflict-event ingest feed → `GlobalPerspectiveGdeltConflict`. |\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d, in-scope — G6 drops pure sporting results from 2026-07-07) and gated-out triggers are recorded in `capture.dropped` with their `gate`; a scenario with no surviving dated trigger carries `scoreable:false`. `finalVerdict`/`confirmedBy:'agent-verified'` written by the Phase-2 agent loop (first run 2026-07-07). Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Populated live by the scheduled detector (`newsBreakingAlert` runs 4-hourly, `DRY_RUN=false`) — see [Lambda #21](#21-newsbreakingalert--deployed--scheduled-proposal-mode-dry_runfalse) + `BREAKING_ALERTS_PLAN.md`.\n\n### Signal-API + ingest tables (`signal-api` branch — live in prod, documented 2026-07-25)\nThese exist in prod DynamoDB but their producers' source is on the unmerged `signal-api` branch (see \"Prod infra NOT on `main`\" under the Lambda inventory). Key schemas confirmed via `describe-table` 2026-07-25:\n\n| Table | Key | Written by | Contents |\n|-------|-----|-----------|----------|\n| `GlobalPerspectiveSignals` | `signal_id` | newsSignals | Signal-API v1 records (120d TTL) |\n| `GlobalPerspectiveApiKeys` | `keyHash` | mint-key.mjs / newsSignals | Signal-API access keys |\n| `GlobalPerspectiveGdacsEvents` | (see describe-table) | newsGdacsIngest | GDACS disaster-alert ingest |\n| `GlobalPerspectiveGdeltConflict` | (see describe-table) | newsGdeltConflict | GDELT conflict-event ingest |\n| `GlobalPerspectiveImpactAudit` | (see describe-table) | newsImpactAudit | Coverage-audit results |\n| `GlobalPerspectiveIngestCapture` | (see describe-table) | ingest feeds | Raw high-impact event capture (audited by newsImpactAudit) |\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G6** (`src/lib.js`; G6 = sporting-result scope, added 2026-07-07) → structured, forward-dated, in-scope, `methodologyVersion:1` triggers; malformed/out-of-scope ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4, runbook `predictions/V1_RESOLUTION_RUNBOOK.md`): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface. **Two runs done (weekly cadence):** 07-07 scored 16 of 21 due triggers (5 deferred, window still open on run day); **07-08 scored all 21 due triggers** (incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers, cleared on recheck). Same-day-deadline learning: verifiers need a \"defer\" option when the event window hasn't closed (not yet folded into the runbook doc). Live totals: resolved 35, fired 9, Brier 0.157 (up from 0.084 after run 1 — run 2 added real high-stakes calls with genuine misses, the honest direction; the 60-80% bucket runs ~10pts hot but the curve stays monotonic).\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n5. **Feedback (Phase 5, DESIGNED 2026-07-08 — data-gated, NOT built)** — `CALIBRATION_DIGEST_PLAN.md`: a weekly `newsCalibrationDigest` Lambda will compact the resolved-trigger trace into one ~200-token `CALIBRATION#DIGEST` row (probability-bucket calibration + per-reference-class fire rates + deterministic bias lines); generation GetItems it and injects a `calibrationBlock` into `buildPredictionPrompt`, stamping `methodologyVersion:2` (the flat-65% v1 era stays as control group). Build gates: CP-1 manual measure at ~50 resolved; CP-2 infra at ~150 resolved. Honesty rule: feedback changes what the model says NEXT, never remaps stored/displayed probabilities. Digest rows must NOT carry `methodologyVersion` (the extract script and `prediction_track_record` filter on it).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-07-25)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. **EventBridge Rules run in UTC; several EventBridge *Scheduler* entries run in `Asia/Tokyo` — do NOT assume UTC for the Scheduler table** (the \"All times UTC\" claim was wrong pre-2026-07-25: 4 of 7 schedulers are JST — see the timezone column below).\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n| `TriggerPairIntelligenceWeekly` | `cron(0 8 ? * MON *)` | newsPairIntelligence (#8 — Mondays 08:00 UTC; **NOT manual-only**, corrected 2026-07-25) |\n| `TriggerBreakingAlert` | `cron(15 */4 * * ? *)` | newsBreakingAlert (#21 — 4-hourly, ENABLED, `DRY_RUN=false`) |\n| `TriggerDriftCorrector` | `cron(20 7 * * ? *)` | newsDriftCorrector (#26 — 07:20 UTC daily) |\n| `TriggerWeeklyEmailSend` | `cron(0 14 ? * SUN *)` | newsEmailSender (#29 — weekly digest, ENABLED) |\n| `TriggerBreakingEmailSend` | `rate(15 minutes)` | newsEmailSender (#29 — breaking sends, ENABLED) |\n| `TriggerDriftEmailSend` | `cron(40 7 * * ? *)` | newsEmailSender (#29 — drift alerts, **DISABLED** by design) |\n| `TriggerFreshnessMonitor` | `cron(30 0/2 * * ? *)` | newsFreshnessMonitor (#18) |\n| `TriggerErrorDigest` | `cron(15 0/6 * * ? *)` | newsErrorDigest (#19) |\n| `TriggerSignalsBuild` | `cron(0 10 * * ? *)` | newsSignals (Signal-API — `signal-api` branch, deployed to prod; see \"Prod infra not on `main`\") |\n| `TriggerImpactAudit` | `cron(0 9 * * ? *)` | newsImpactAudit (impact-first — `signal-api` branch) |\n| `TriggerGdacsIngest` | `cron(0 */6 * * ? *)` | newsGdacsIngest (GDACS ingest — `signal-api` branch) |\n| `TriggerGdeltConflict` | `cron(0 */6 * * ? *)` | newsGdeltConflict (GDELT ingest — `signal-api` branch) |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` **TZ Asia/Tokyo** | newsPostLinkedin (:20 past JST hours 0/3/6/… = **15/18/21/00/… UTC** — NOT UTC, corrected 2026-07-25) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` **TZ Asia/Tokyo** | linkedInAutoPost (07:30 / 19:30 **JST** = **22:30 / 10:30 UTC** — corrected 2026-07-25) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` **TZ Asia/Tokyo** | newsCountryFactsUpdater (05:00 **JST** = **20:00 UTC** prior day — corrected 2026-07-25) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual / user-triggered only)\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST / Function URL\n- (`newsPairIntelligence` was listed here as \"manual only\" — **corrected 2026-07-25**: it is now on the ENABLED weekly rule `TriggerPairIntelligenceWeekly`, Mondays 08:00 UTC.)\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~67 component files (in `src/components/` + 19 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. **Corrections ledger now also renders `FollowButton` on country-scope rows** (2026-07-08, `FOLLOW_SURFACING_PLAN.md`, `b455ee1`) — sibling of the name link; `FollowButton` was CountryPage-only until now. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). **07-09:** accepts `?stories=<id>[,…]` preselect (seeds once on topics-load; unknown ids dropped) — deep-linked from Home per-story \"Analyze →\", ThreadPage \"Analyze this arc\", `/economy`. **07-10:** output is ICD-203-structured (Key Judgments + yardstick + confidence axis + indicators table); the trailing `gp-struct` JSON block is extracted by `utils/analysisStruct.js` (validator/Markdown get stripped prose) and rendered as `atoms/AnalysisVisuals.jsx` **ScenarioBars/IndicatorMatrix/RippleTable** (struct null → prose-only honest fallback). **07-10 (later, `4193610`):** prompt fix raised DeepSeek's `gp-struct` emission 1/4→8/8 so the visuals render reliably; **Alibaba Qwen** added to the provider list with a workspace base-URL override for `sk-ws-` keys. |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n34 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter / **Alibaba Qwen**) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Qwen (added 2026-07-10)** — flagship `qwen3.7-max` (+`qwen3-max`/`qwen3.7-plus`), `enable_thinking:false` (same non-streaming reason as DeepSeek V4); its keyHint/comment flag that Alibaba **workspace-scoped `sk-ws-…` keys** 401 the default `dashscope-intl` host and need the WorkspaceId-specific `…maas.aliyuncs.com` host → handled by `allowBaseUrlOverride` + the optional \"Endpoint URL\" field in `ProviderModal` (persisted in `byok.js`, applied via `runChat({baseUrl})`→`baseUrl||provider.baseUrl`; every other provider unchanged when blank). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Professional structure (2026-07-10, `ANALYSIS_PRO_STRUCTURE_PLAN.md`):** prompts upgraded to ICD-203/desk-note packaging — Key Judgments box (BLUF, each judgment = yardstick probability term + separate justified confidence), the ICD-203 probability yardstick verbatim, an `| Indicator to watch | Confirms | Kills |` table on the scenario lens, and a `gp-struct` machine-readable block → ScenarioBars/IndicatorMatrix/RippleTable visuals (a chart can only show numbers the prose already states; every rule carries an omit-when-unsupported escape hatch — the 06-13 structure-pressure-induces-fabrication lesson). Server prompt (`newsAnalyze`) synced verbatim; guided maxTokens 1600→2400.\n- **Surfacing (2026-07-09, `ANALYZE_SURFACING_PLAN.md`):** was ONE nav link → now Home per-story \"Analyze →\" pill + hero line, ThreadPage \"Analyze this arc →\", `/economy` cross-link, all deep-linking via `?stories=` preselect.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — **only `newsPolarBilling` and `newsAnalyze` own CORS in code** (`corsHeaders` + an OPTIONS 204 handler); their Function-URL CORS config **must stay empty**, or AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser; bit us at the Polar checkout 2026-06-30). **Corrected 2026-07-25 (staleness audit):** `newsSavedItems` and `newsRecommend` do the **opposite** — their code emits **no** ACAO and they rely on a **populated** Function-URL CORS config. Do **not** clear their Function-URL CORS (that would remove all CORS and break them in-browser). Rule of thumb before touching any function's Function-URL CORS: grep the source for `Access-Control-Allow-Origin` first — code emits it ⇒ Function-URL CORS empty; code doesn't ⇒ Function-URL CORS populated.\n"}, "d27c84ea44309f23f105bd62ca0bac2b53b2376f": {"t": 1785765790.3720286, "n": 175340, "h": "c5002eda3cbfc233", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-25 (refresh: **AGENT-REVIEW STALENESS AUDIT** — a 5-auditor parallel sweep verified the whole doc against live AWS (`AGENT_REVIEW_METHOD.md`). Fixes landed: (1) **`newsBreakingAlert` #21 is DEPLOYED + scheduled + `DRY_RUN=false`**, not \"BUILT — NOT DEPLOYED\" (still human-confirm-before-send); (2) **4 of 7 EventBridge *Scheduler* entries run `Asia/Tokyo`, not UTC** — `InvokeLinkedIn`/`LinkedinThreadsDaily`/`Fact` corrected (`InvokeDev` was already right); the \"All times UTC\" header was wrong; (3) **`newsPairIntelligence` #8 is on the ENABLED weekly rule `TriggerPairIntelligenceWeekly`**, not manual-only; (4) **Common Mistakes #7 CORS list corrected** — only `newsPolarBilling`+`newsAnalyze` own CORS in code (Function-URL CORS empty); `newsSavedItems`+`newsRecommend` do the opposite (populated Function-URL CORS, no ACAO in code) — clearing theirs would break them; (5) **a Signal-API + impact-first ingest subsystem** (`newsSignals`/`newsImpactAudit`/`newsGdacsIngest`/`newsGdeltConflict` + 6 DDB tables) is live in prod from the unmerged `signal-api` branch — now documented as \"Prod infra NOT on `main`\"; (6) stale counts: proxy actions ~27→~33, components ~64→67, atoms 17→19, hooks 32→34; `newsPostLinkedin` env `SOCIAL_POSTS_TABLE`→deployed `LINKEDIN_POSTS_TABLE`. Backend #10–20 + frontend routes/components/hooks mappings verified CLEAN. Note to reconcile: **CLAUDE.md's Dual-CORS bullet + memory `feedback_lambda_function_url_cors` still carry the old 4-function CORS list** (fix #4 above). Prior: **ANALYSIS STUDIO — VISUAL-BLOCK RELIABILITY FIX + QWEN PROVIDER** [[project_analysis_studio]]. 07-10 [`4193610`, `QWEN_AND_VISUAL_BLOCK_PLAN.md`]: a **qwen3.7-max vs deepseek-v4-pro bake-off** (same stories/lenses, shipped pipeline) exposed the real \"where's the graph?\" cause — DeepSeek (the member-path model) emitted the `gp-struct` block in only **1/4** runs (writes the scenario prose but skips the machine-readable block), so the 07-10 ScenarioBars/IndicatorMatrix/RippleTable rarely rendered for members; Qwen emitted 4/4. **Fix (prompt-only, `analysisPrompt.js`):** embedded a fully-filled JSON template in SYSTEM_PROMPT + a mandatory tail reminder inside each lens task (where the model finishes) + removed the easy \"omit the whole block\" exit for scenario/economic lenses → valid-block emission **1/4 → 8/8** live (`scratchpad/emit-test.mjs`); honesty intact — `validateStruct` still strips any figure not literally in the prose, so all 8 validated with zero fabrication. `newsAnalyze` Lambda re-synced via the **patched-deployed-zip** method (Node made deployed SYSTEM_PROMPT byte-equal to the frontend, `FE===BE` 5278 chars; deployed bytes verified: template present, omit-exit gone, `creditBalance`=0, `MAX_TOKENS`=2400; lens-task tail reminders ride in via the frontend-built `body.user`). **Bake-off verdict:** DeepSeek stays the member-path default (tighter/MECE probability bands, deeper prose, always states confidence); **Qwen3.7-max added as a faster (~36%) BYOK option** — but Alibaba's international keys are **WORKSPACE-scoped (`sk-ws-…`)** and 401 the generic `dashscope-intl` host, needing `https://<WorkspaceId>.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1` (Tokyo; verified with `ws-gl08109galyxzzh3`), so a per-provider **base-URL override** was added (`allowBaseUrlOverride` on qwen; `runChat`/`runOpenAICompat` take optional `baseUrl`→`baseUrl||provider.baseUrl`; ProviderModal optional \"Endpoint URL\" field; `byok.js` persists it back-compat) — live-tested end-to-end through real `runChat` (override→200, default→401). Separately (same day, prod-config only): the member path itself was switched to **`deepseek-v4-pro`** (`ANALYZE_DAILY_CAP` 50→100, `Timeout` 30s→120s), which required patching `runDeepSeek` to send `thinking:{type:'disabled'}` — v4-pro's default thinking mode otherwise burns the whole `MAX_TOKENS` budget and returns empty output — and surfaced a lesson that a timed-out synchronous Function-URL Lambda returns no-ACAO, which looks like CORS but isn't. Prior: **ANALYSIS STUDIO SURFACED + PROFESSIONAL STRUCTURE**. 07-09 [`8cf2b29`, `ANALYZE_SURFACING_PLAN.md`]: `/analyze` was discoverable from ONE nav link (~2 runs/30d = discovery gap, not quality — engine live-tested 0/3 fabrication, and a manual web truth-check found every auditor flag was a FALSE POSITIVE vs ground truth); surfaced via a per-story **\"Analyze →\"** pill on Home (4th control beside Summary/Predict/Trace), a Home hero value-prop line, ThreadPage **\"Analyze this arc →\"** (deep-links `thread.entries[0].topicId`), an `/economy` cross-link, and a new **`?stories=<id>[,…]` preselect** in `AnalysisStudio.jsx` (seeds once on topics-load, unknown ids silently dropped). 07-10 [`c429931`, `ANALYSIS_PRO_STRUCTURE_PLAN.md`]: output upgraded to ICD-203/desk-note packaging — **Key Judgments box** (BLUF bullets), the **ICD-203 probability yardstick** verbatim (\"very likely (80–95%)\"), **confidence as a separate justified axis**, a consolidated **`| Indicator to watch | Confirms | Kills |` table** on the scenario lens, and a fenced **`gp-struct` JSON block** (a machine-readable INDEX of the prose — never new content) parsed by new `utils/analysisStruct.js` (truncation-safe; drops scenarios whose probability digits aren't literally in the prose) → rendered by `atoms/AnalysisVisuals.jsx` (**ScenarioBars/IndicatorMatrix/RippleTable**, pure CSS). Validator + Markdown always receive the STRIPPED prose. Review caught 4 pre-deploy bugs: `newsAnalyze`'s server-pinned prompt had **DRIFTED** (missing all 06-13 upgrades — members got a weaker analyst than BYOK; now synced verbatim), gp-struct truncation at 1600 tokens (guided maxTokens→2400, Studio+eval+Lambda env), and two validator false-positives from the new structure (comparison-operator thresholds `>50%`; scenario-heading ranges — estimative look-behind 32→48; goldens added). **⚠️ prod `newsAnalyze` deploy = PROMPT-PATCH of the downloaded DEPLOYED (pre-credits 06-16) zip — the repo file carries the PARKED credits code and must NOT reach prod until `PROD_CREDITS_NEXT_STEPS.md` runs.** Prior: **SECOND PREDICTION RESOLUTION RUN + DRIFT-EMAIL PER-AXIS VIEW + FOLLOWBUTTON ON THE LEDGER** [[project_prediction_methodology_v1]] [[project_scoring_model_v2]]. The 07-08 agent-run resolution sweep scored **all 21 due triggers** [incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers from the 07-07 run, cleared on recheck]. **`/track-record` now shows resolved 35 (was 15), fired 9 (was 4), Brier 0.157 (was 0.084)**; calibration buckets `0-20%`→stated ~17%/fired 9% (n11) · `20-40%`→stated ~20%/fired 9% (n11) · `60-80%`→stated ~65%/fired 54% (n13). Brier rose on purpose: run-1 was flattered by easy low-probability tails; run-2 added real high-stakes calls with genuine misses — the honest direction. The 65% bucket runs ~10pts hot (54% actual) but the curve is monotonic (9%→9%→54%). Cadence confirmed weekly agent-in-session. Prior (07-08, both already on main): **drift-alert email → per-axis view** [`0ec494f`, `SCORING_MODEL_V2_PLAN.md` §12] — `newsDriftCorrector` now embeds `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel` onto each new `DRIFT#`/`DRIFTLOG#` country note; `renderDriftEmail.js` renders a degradation ladder (currentDimensions → Level B 4-axis scorecard `HIGH · Humanitarian 82` + `⚠ N/4 elevated` → changeDimensions → Level A per-axis pills → legacy scalar); not retroactive. **FollowButton surfaced on the `/track-record` corrections ledger** [`b455ee1`, `FOLLOW_SURFACING_PLAN.md`] — the member country-follow control (previously CountryPage-only) now also renders as a sibling of the name link on each **country-scope** ledger row, the highest-intent \"get the next change emailed\" moment; management still lives at `/account?tab=notifications`. Deferred surfaces: CountryWhatChanged band (redundant), ThreadPage (multi-country ambiguity), countries-index (nesting/grid). Prior: **FIRST PREDICTION RESOLUTION RUN DONE + G6 SPORTS-SCOPE GATE LIVE** [[project_prediction_methodology_v1]]. The accountability record is no longer empty: the first agent-run resolution loop (`predictions/V1_RESOLUTION_RUNBOOK.md`) scored **16 of 21 due triggers** — 3 parallel blind web-grounded verifiers → 2nd independent blind pass on every `fired` → operator ~10% spot-check → dry-run → `resolve-v1-write.js --commit`. **`/track-record` now shows Brier 0.084**, resolved 15 (fired 4 · not_fired 11) + 1 unclear, calibration buckets 0-20%→0% (n6) · 20-40%→0% (n4) · 60-80%→80% (n5) [caveat: n tiny, 11/15 were low-p tails correctly not firing = easy points]; 5 triggers deferred (window still open on run day — Le Pen ruling ×3, INDOPACOM, Andersen). Deadlines arrived earlier than the ~07-11 estimate (short-horizon triggers). **New capture gate G6** [`NewsProjectInvokeAgentLambda/src/lib.js`, deployed to `-dev` + deployed-bytes verified; source `91e069e` + deploy `73e490d`]: drops **pure sporting-result** triggers — a match/tournament outcome or on-field participation carries no geopolitical/economic/institutional signal (the remit). Rule = drop only when BOTH a sporting-competition **context** regex AND a **result/participation** regex match, so sport-**adjacent** governance triggers survive (no result verb: \"Belgium files a FIFA protest\", \"CAS grants an interim measure\" pass). **Corpus-validated: across all 1038 live v1 triggers G6 drops exactly the 2 Trump–FIFA/Balogun box-score triggers, 0 false positives**; `node --test` 21/21. NOT retroactive (already-published sport triggers stay scored); scope decision was fix-forward at the trigger layer, NOT an ingest ban. Methodology disclosure live on `/track-record`. Cadence = weekly agent-in-session. Prior: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek v4-pro** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=100`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~33 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check · **G6 capture-scope — drops pure sporting results, added 2026-07-07**) before it can enter the immutable log — a malformed or out-of-scope trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. G6 fires only when a trigger has BOTH a sporting-competition context AND a result/participation verb (so sport-adjacent governance triggers survive); corpus-validated 2/1038, 0 false positives. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (21/21, uses the real 2026-07-04-pilot defect triggers + the 2026-07-07 Balogun box-score triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. **Two agent-run resolution sweeps done (07-07, 07-08) → live: resolved 35, fired 9, Brier 0.157, calibration buckets populated** (was honest-empty before 07-07) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)`, **timezone `Asia/Tokyo`** (verified 2026-07-25 — the scheduler is NOT in UTC, same as `newsPostDevTo` #7). Fires at :20 past JST hours 0/3/6/… = **15/18/21/00/… UTC**.\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `LINKEDIN_POSTS_TABLE` (=`NewsProject-linkedin-posts`; the deployed var is **not** `SOCIAL_POSTS_TABLE` — code reads `SOCIAL_POSTS_TABLE || LINKEDIN_POSTS_TABLE` so the fallback wins), `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPairIntelligenceWeekly` — **ENABLED** — `cron(0 8 ? * MON *)` (Mondays 08:00 UTC; **corrected 2026-07-25 — was documented as manual-only**). Also invocable manually with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for the default 10 pairs.\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Runs weekly via `TriggerPairIntelligenceWeekly` (see trigger above) plus ad-hoc manual invokes.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` — DEPLOYED + SCHEDULED (proposal mode; `DRY_RUN=false`)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerBreakingAlert` — **ENABLED** — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis).\n\n> ⚠️ **Doc corrected 2026-07-25 (agent-review staleness audit):** this function was long documented as \"BUILT — NOT DEPLOYED (dry-run)\". Live AWS shows it is **deployed** (Timeout 120s / 256MB), driven by the **ENABLED** rule above, wired to the `GlobalPerspectiveBreakingAlerts` table, with **`DRY_RUN=false`**. What is still true is the **human-in-the-loop / never-auto-sends** design: even at `DRY_RUN=false` the code only writes a `status:'proposed'` row and logs \"awaiting human confirmation before send\" — the actual email send lives in `newsEmailSender` breaking mode (#29), gated on a human `status:'confirmed'`. So \"proposes automatically, sends only after human confirm,\" **not** \"dry-run/un-deployed.\"\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` runs live on its 4-hourly schedule writing structured `status:'proposed'` records; legacy confirmed records render via the saved-email-text fallback in `get_alert`.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (deployed, verified 2026-07-25):** `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE` (=`SummarizeAndPredict`), `BREAKING_ALERTS_TABLE` (=`GlobalPerspectiveBreakingAlerts`), `RESEND_API_KEY` (set), `SITE_URL`, `DRY_RUN` (=`false`), plus `ALERT_EMAIL_FROM` (=`onboarding@resend.dev`) and `ALERT_EMAIL_TO` (operator address). `SIGNIFICANCE_THRESHOLD`/`DEDUPE_DAYS` are **not** set → code defaults only. No Function URL (this is a scheduled Lambda, not URL-invoked).\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars (deployed, verified 2026-07-25):** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `USERS_DDB_TABLE` (=`GlobalPerspectiveUserTable`, added for the follow-country tier check), `FIREBASE_PROJECT_ID`. (`BREAKING_ALERTS_TABLE` and `SITE_URL` are **not** set in the deployed env — code falls back to literals.) **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered. Country-note fields: `changeScore`/`changeLevel`/`changeDimensions{axis:{from,to,delta}}` (what moved) + `priorHeadline`/`currentHeadline` + `triggerEvent`/`whyChanged`/`noSingleDriver`, **and (scoring-v2 Phase D-email, 2026-07-08) the current standing `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel`** for the email's Level-B scorecard (the vector was already loaded for `changeDimensions`; not backfilled — new notes only).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**⚠️ DEPLOYED ≠ REPO (2026-07-10):** prod still runs the **pre-credits 06-16 code** (gate = `ANALYZE_DAILY_CAP=50`) with the **P1 professional-structure SYSTEM_PROMPT patched in** (2026-07-10: downloaded the deployed zip, patched ONLY the prompt block, redeployed; `MAX_TOKENS` env 1600→2400 for the longer structured output). The server-pinned prompt is now mirrored **verbatim** from the frontend's `utils/analysisPrompt.js` — it had silently drifted (missing all 06-13 upgrades, so member runs got a weaker analyst than BYOK). **Re-synced again 2026-07-10 (`4193610`)** with the **gp-struct visual-block reliability fix** (embedded JSON template + emission mandate — raised valid-block emission on the DeepSeek member path from 1/4 to 8/8, so the ScenarioBars/etc. now actually render for members); the lens-task tail reminders reach the model via the frontend-built `body.user` (the Lambda only holds `SYSTEM_PROMPT`), so syncing that array was sufficient. Same patched-zip method: `FE===BE` verified byte-for-byte (5278 chars). **Never zip the repo `src/index.js` to prod until `PROD_CREDITS_NEXT_STEPS.md` runs** — it carries the parked credits code, and deploying it without the env-ordering steps can mis-grant orders. **Model switch + timeout fix (2026-07-10, prod-config only):** the member-path model was switched `deepseek-chat` (V4 Flash) → **`deepseek-v4-pro`** (env `GROK_MODEL`, the bake-off's stronger analyst), with a required code patch (same patched-deployed-zip method) sending `thinking:{type:'disabled'}` in `runDeepSeek` — v4-pro defaults to thinking mode, which without this burns the whole `MAX_TOKENS` budget on `reasoning_content` and returns **empty output** (`len=0, finish_reason=length`, proven live before the fix; code patch deployed before the env flip so there was no broken window). `ANALYZE_DAILY_CAP` raised 50→100 and `Timeout` raised 30s→120s (v4-pro takes ~40–60s; the old 30s timeout surfaced in-browser as a fake-CORS **\"Failed to fetch\" / \"No 'Access-Control-Allow-Origin' header\"** error — the Function-URL CORS config was correctly empty throughout, so this was NOT the dual-CORS trap: on a synchronous Function-URL Lambda, a timed-out invocation returns a bare platform error with no CORS headers, which reads as CORS but is actually a timeout).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live). **Per-axis view — scoring-v2 Phase D-email (2026-07-08, `SCORING_MODEL_V2_PLAN.md` §12):** `renderDriftEmail` renders a degradation ladder — `note.currentDimensions` → **Level B** compact scorecard (`HIGH · Humanitarian 82` + 4-axis row + `⚠ N/4 elevated`); else `changeDimensions` → **Level A** per-axis change pills; else the legacy scalar. No frontend-util import (reads the note's derived headline fields; breadth flag inline). Not retroactive — Level B only for notes written after the corrector deploy.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n### Prod infra NOT on `main` — Signal-API + impact-first ingest (documented 2026-07-25)\n\nFour Lambdas are **deployed and ENABLED in prod** but their **source lives only on the unmerged `signal-api` branch/worktree**, so they had no entry here. They pair with 4 undocumented DDB tables (below). Flagged by the 2026-07-25 staleness audit; source not on `main` means the deployed-bytes are the only reference — verify against live AWS before changing them. See [[project_signal_api_deployed]], [[project_impact_first_redesign]], `signal-api` branch.\n\n| Function | Trigger (ENABLED) | Config | Role (from deployed env + tables) |\n|---|---|---|---|\n| **`newsSignals`** | `TriggerSignalsBuild` `cron(0 10 * * ? *)` + a public **Function URL** (AuthType NONE, key-gated in code) | 300s / 512MB | Signal-API v1 builder — writes `GlobalPerspectiveSignals` (120d TTL) from `PredictionLog`/`SummarizeAndPredict`/`BreakingAlerts`; `API_KEYS_TABLE=GlobalPerspectiveApiKeys`, `FREE_DELAY_HOURS=24` (paid-tier freshness gate). |\n| **`newsImpactAudit`** | `TriggerImpactAudit` `cron(0 9 * * ? *)` | 120s / 256MB | Coverage dead-man's-switch — DeepSeek audit of whether high-impact events were MISSED; `IMPACT_AUDIT_TABLE`/`INGEST_CAPTURE_TABLE`, SNS→`GlobalPerspectiveAlerts` when misses ≥ `MISS_ALERT_THRESHOLD=2`. |\n| **`newsGdacsIngest`** | `TriggerGdacsIngest` `cron(0 */6 * * ? *)` | 120s / 256MB | GDACS disaster-alert ingest feed → `GlobalPerspectiveGdacsEvents`. |\n| **`newsGdeltConflict`** | `TriggerGdeltConflict` `cron(0 */6 * * ? *)` | 120s / 512MB | GDELT conflict-event ingest feed → `GlobalPerspectiveGdeltConflict`. |\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d, in-scope — G6 drops pure sporting results from 2026-07-07) and gated-out triggers are recorded in `capture.dropped` with their `gate`; a scenario with no surviving dated trigger carries `scoreable:false`. `finalVerdict`/`confirmedBy:'agent-verified'` written by the Phase-2 agent loop (first run 2026-07-07). Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Populated live by the scheduled detector (`newsBreakingAlert` runs 4-hourly, `DRY_RUN=false`) — see [Lambda #21](#21-newsbreakingalert--deployed--scheduled-proposal-mode-dry_runfalse) + `BREAKING_ALERTS_PLAN.md`.\n\n### Signal-API + ingest tables (`signal-api` branch — live in prod, documented 2026-07-25)\nThese exist in prod DynamoDB but their producers' source is on the unmerged `signal-api` branch (see \"Prod infra NOT on `main`\" under the Lambda inventory). Key schemas confirmed via `describe-table` 2026-07-25:\n\n| Table | Key | Written by | Contents |\n|-------|-----|-----------|----------|\n| `GlobalPerspectiveSignals` | `signal_id` | newsSignals | Signal-API v1 records (120d TTL) |\n| `GlobalPerspectiveApiKeys` | `keyHash` | mint-key.mjs / newsSignals | Signal-API access keys |\n| `GlobalPerspectiveGdacsEvents` | (see describe-table) | newsGdacsIngest | GDACS disaster-alert ingest |\n| `GlobalPerspectiveGdeltConflict` | (see describe-table) | newsGdeltConflict | GDELT conflict-event ingest |\n| `GlobalPerspectiveImpactAudit` | (see describe-table) | newsImpactAudit | Coverage-audit results |\n| `GlobalPerspectiveIngestCapture` | (see describe-table) | ingest feeds | Raw high-impact event capture (audited by newsImpactAudit) |\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G6** (`src/lib.js`; G6 = sporting-result scope, added 2026-07-07) → structured, forward-dated, in-scope, `methodologyVersion:1` triggers; malformed/out-of-scope ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4, runbook `predictions/V1_RESOLUTION_RUNBOOK.md`): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface. **Two runs done (weekly cadence):** 07-07 scored 16 of 21 due triggers (5 deferred, window still open on run day); **07-08 scored all 21 due triggers** (incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers, cleared on recheck). Same-day-deadline learning: verifiers need a \"defer\" option when the event window hasn't closed (not yet folded into the runbook doc). Live totals: resolved 35, fired 9, Brier 0.157 (up from 0.084 after run 1 — run 2 added real high-stakes calls with genuine misses, the honest direction; the 60-80% bucket runs ~10pts hot but the curve stays monotonic).\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n5. **Feedback (Phase 5, DESIGNED 2026-07-08 — data-gated, NOT built)** — `CALIBRATION_DIGEST_PLAN.md`: a weekly `newsCalibrationDigest` Lambda will compact the resolved-trigger trace into one ~200-token `CALIBRATION#DIGEST` row (probability-bucket calibration + per-reference-class fire rates + deterministic bias lines); generation GetItems it and injects a `calibrationBlock` into `buildPredictionPrompt`, stamping `methodologyVersion:2` (the flat-65% v1 era stays as control group). Build gates: CP-1 manual measure at ~50 resolved; CP-2 infra at ~150 resolved. Honesty rule: feedback changes what the model says NEXT, never remaps stored/displayed probabilities. Digest rows must NOT carry `methodologyVersion` (the extract script and `prediction_track_record` filter on it).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-07-25)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. **EventBridge Rules run in UTC; several EventBridge *Scheduler* entries run in `Asia/Tokyo` — do NOT assume UTC for the Scheduler table** (the \"All times UTC\" claim was wrong pre-2026-07-25: 4 of 7 schedulers are JST — see the timezone column below).\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n| `TriggerPairIntelligenceWeekly` | `cron(0 8 ? * MON *)` | newsPairIntelligence (#8 — Mondays 08:00 UTC; **NOT manual-only**, corrected 2026-07-25) |\n| `TriggerBreakingAlert` | `cron(15 */4 * * ? *)` | newsBreakingAlert (#21 — 4-hourly, ENABLED, `DRY_RUN=false`) |\n| `TriggerDriftCorrector` | `cron(20 7 * * ? *)` | newsDriftCorrector (#26 — 07:20 UTC daily) |\n| `TriggerWeeklyEmailSend` | `cron(0 14 ? * SUN *)` | newsEmailSender (#29 — weekly digest, ENABLED) |\n| `TriggerBreakingEmailSend` | `rate(15 minutes)` | newsEmailSender (#29 — breaking sends, ENABLED) |\n| `TriggerDriftEmailSend` | `cron(40 7 * * ? *)` | newsEmailSender (#29 — drift alerts, **DISABLED** by design) |\n| `TriggerFreshnessMonitor` | `cron(30 0/2 * * ? *)` | newsFreshnessMonitor (#18) |\n| `TriggerErrorDigest` | `cron(15 0/6 * * ? *)` | newsErrorDigest (#19) |\n| `TriggerSignalsBuild` | `cron(0 10 * * ? *)` | newsSignals (Signal-API — `signal-api` branch, deployed to prod; see \"Prod infra not on `main`\") |\n| `TriggerImpactAudit` | `cron(0 9 * * ? *)` | newsImpactAudit (impact-first — `signal-api` branch) |\n| `TriggerGdacsIngest` | `cron(0 */6 * * ? *)` | newsGdacsIngest (GDACS ingest — `signal-api` branch) |\n| `TriggerGdeltConflict` | `cron(0 */6 * * ? *)` | newsGdeltConflict (GDELT ingest — `signal-api` branch) |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` **TZ Asia/Tokyo** | newsPostLinkedin (:20 past JST hours 0/3/6/… = **15/18/21/00/… UTC** — NOT UTC, corrected 2026-07-25) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` **TZ Asia/Tokyo** | linkedInAutoPost (07:30 / 19:30 **JST** = **22:30 / 10:30 UTC** — corrected 2026-07-25) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` **TZ Asia/Tokyo** | newsCountryFactsUpdater (05:00 **JST** = **20:00 UTC** prior day — corrected 2026-07-25) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual / user-triggered only)\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST / Function URL\n- (`newsPairIntelligence` was listed here as \"manual only\" — **corrected 2026-07-25**: it is now on the ENABLED weekly rule `TriggerPairIntelligenceWeekly`, Mondays 08:00 UTC.)\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~67 component files (in `src/components/` + 19 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. **Corrections ledger now also renders `FollowButton` on country-scope rows** (2026-07-08, `FOLLOW_SURFACING_PLAN.md`, `b455ee1`) — sibling of the name link; `FollowButton` was CountryPage-only until now. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). **07-09:** accepts `?stories=<id>[,…]` preselect (seeds once on topics-load; unknown ids dropped) — deep-linked from Home per-story \"Analyze →\", ThreadPage \"Analyze this arc\", `/economy`. **07-10:** output is ICD-203-structured (Key Judgments + yardstick + confidence axis + indicators table); the trailing `gp-struct` JSON block is extracted by `utils/analysisStruct.js` (validator/Markdown get stripped prose) and rendered as `atoms/AnalysisVisuals.jsx` **ScenarioBars/IndicatorMatrix/RippleTable** (struct null → prose-only honest fallback). **07-10 (later, `4193610`):** prompt fix raised DeepSeek's `gp-struct` emission 1/4→8/8 so the visuals render reliably; **Alibaba Qwen** added to the provider list with a workspace base-URL override for `sk-ws-` keys. |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n34 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter / **Alibaba Qwen**) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Qwen (added 2026-07-10)** — flagship `qwen3.7-max` (+`qwen3-max`/`qwen3.7-plus`), `enable_thinking:false` (same non-streaming reason as DeepSeek V4); its keyHint/comment flag that Alibaba **workspace-scoped `sk-ws-…` keys** 401 the default `dashscope-intl` host and need the WorkspaceId-specific `…maas.aliyuncs.com` host → handled by `allowBaseUrlOverride` + the optional \"Endpoint URL\" field in `ProviderModal` (persisted in `byok.js`, applied via `runChat({baseUrl})`→`baseUrl||provider.baseUrl`; every other provider unchanged when blank). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Professional structure (2026-07-10, `ANALYSIS_PRO_STRUCTURE_PLAN.md`):** prompts upgraded to ICD-203/desk-note packaging — Key Judgments box (BLUF, each judgment = yardstick probability term + separate justified confidence), the ICD-203 probability yardstick verbatim, an `| Indicator to watch | Confirms | Kills |` table on the scenario lens, and a `gp-struct` machine-readable block → ScenarioBars/IndicatorMatrix/RippleTable visuals (a chart can only show numbers the prose already states; every rule carries an omit-when-unsupported escape hatch — the 06-13 structure-pressure-induces-fabrication lesson). Server prompt (`newsAnalyze`) synced verbatim; guided maxTokens 1600→2400.\n- **Surfacing (2026-07-09, `ANALYZE_SURFACING_PLAN.md`):** was ONE nav link → now Home per-story \"Analyze →\" pill + hero line, ThreadPage \"Analyze this arc →\", `/economy` cross-link, all deep-linking via `?stories=` preselect.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — **only `newsPolarBilling` and `newsAnalyze` own CORS in code** (`corsHeaders` + an OPTIONS 204 handler); their Function-URL CORS config **must stay empty**, or AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser; bit us at the Polar checkout 2026-06-30). **Corrected 2026-07-25 (staleness audit):** `newsSavedItems` and `newsRecommend` do the **opposite** — their code emits **no** ACAO and they rely on a **populated** Function-URL CORS config. Do **not** clear their Function-URL CORS (that would remove all CORS and break them in-browser). Rule of thumb before touching any function's Function-URL CORS: grep the source for `Access-Control-Allow-Origin` first — code emits it ⇒ Function-URL CORS empty; code doesn't ⇒ Function-URL CORS populated.\n"}, "9bf58f0d410d15c892f2e79bfde2717c3ced97aa": {"t": 1785765792.3639054, "n": 167243, "h": "e3a0c674d87566f2", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-10 (refresh: **ANALYSIS STUDIO — VISUAL-BLOCK RELIABILITY FIX + QWEN PROVIDER** [[project_analysis_studio]]. 07-10 [`4193610`, `QWEN_AND_VISUAL_BLOCK_PLAN.md`]: a **qwen3.7-max vs deepseek-v4-pro bake-off** (same stories/lenses, shipped pipeline) exposed the real \"where's the graph?\" cause — DeepSeek (the member-path model) emitted the `gp-struct` block in only **1/4** runs (writes the scenario prose but skips the machine-readable block), so the 07-10 ScenarioBars/IndicatorMatrix/RippleTable rarely rendered for members; Qwen emitted 4/4. **Fix (prompt-only, `analysisPrompt.js`):** embedded a fully-filled JSON template in SYSTEM_PROMPT + a mandatory tail reminder inside each lens task (where the model finishes) + removed the easy \"omit the whole block\" exit for scenario/economic lenses → valid-block emission **1/4 → 8/8** live (`scratchpad/emit-test.mjs`); honesty intact — `validateStruct` still strips any figure not literally in the prose, so all 8 validated with zero fabrication. `newsAnalyze` Lambda re-synced via the **patched-deployed-zip** method (Node made deployed SYSTEM_PROMPT byte-equal to the frontend, `FE===BE` 5278 chars; deployed bytes verified: template present, omit-exit gone, `creditBalance`=0, `MAX_TOKENS`=2400; lens-task tail reminders ride in via the frontend-built `body.user`). **Bake-off verdict:** DeepSeek stays the member-path default (tighter/MECE probability bands, deeper prose, always states confidence); **Qwen3.7-max added as a faster (~36%) BYOK option** — but Alibaba's international keys are **WORKSPACE-scoped (`sk-ws-…`)** and 401 the generic `dashscope-intl` host, needing `https://<WorkspaceId>.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1` (Tokyo; verified with `ws-gl08109galyxzzh3`), so a per-provider **base-URL override** was added (`allowBaseUrlOverride` on qwen; `runChat`/`runOpenAICompat` take optional `baseUrl`→`baseUrl||provider.baseUrl`; ProviderModal optional \"Endpoint URL\" field; `byok.js` persists it back-compat) — live-tested end-to-end through real `runChat` (override→200, default→401). Separately (same day, prod-config only): the member path itself was switched to **`deepseek-v4-pro`** (`ANALYZE_DAILY_CAP` 50→100, `Timeout` 30s→120s), which required patching `runDeepSeek` to send `thinking:{type:'disabled'}` — v4-pro's default thinking mode otherwise burns the whole `MAX_TOKENS` budget and returns empty output — and surfaced a lesson that a timed-out synchronous Function-URL Lambda returns no-ACAO, which looks like CORS but isn't. Prior: **ANALYSIS STUDIO SURFACED + PROFESSIONAL STRUCTURE**. 07-09 [`8cf2b29`, `ANALYZE_SURFACING_PLAN.md`]: `/analyze` was discoverable from ONE nav link (~2 runs/30d = discovery gap, not quality — engine live-tested 0/3 fabrication, and a manual web truth-check found every auditor flag was a FALSE POSITIVE vs ground truth); surfaced via a per-story **\"Analyze →\"** pill on Home (4th control beside Summary/Predict/Trace), a Home hero value-prop line, ThreadPage **\"Analyze this arc →\"** (deep-links `thread.entries[0].topicId`), an `/economy` cross-link, and a new **`?stories=<id>[,…]` preselect** in `AnalysisStudio.jsx` (seeds once on topics-load, unknown ids silently dropped). 07-10 [`c429931`, `ANALYSIS_PRO_STRUCTURE_PLAN.md`]: output upgraded to ICD-203/desk-note packaging — **Key Judgments box** (BLUF bullets), the **ICD-203 probability yardstick** verbatim (\"very likely (80–95%)\"), **confidence as a separate justified axis**, a consolidated **`| Indicator to watch | Confirms | Kills |` table** on the scenario lens, and a fenced **`gp-struct` JSON block** (a machine-readable INDEX of the prose — never new content) parsed by new `utils/analysisStruct.js` (truncation-safe; drops scenarios whose probability digits aren't literally in the prose) → rendered by `atoms/AnalysisVisuals.jsx` (**ScenarioBars/IndicatorMatrix/RippleTable**, pure CSS). Validator + Markdown always receive the STRIPPED prose. Review caught 4 pre-deploy bugs: `newsAnalyze`'s server-pinned prompt had **DRIFTED** (missing all 06-13 upgrades — members got a weaker analyst than BYOK; now synced verbatim), gp-struct truncation at 1600 tokens (guided maxTokens→2400, Studio+eval+Lambda env), and two validator false-positives from the new structure (comparison-operator thresholds `>50%`; scenario-heading ranges — estimative look-behind 32→48; goldens added). **⚠️ prod `newsAnalyze` deploy = PROMPT-PATCH of the downloaded DEPLOYED (pre-credits 06-16) zip — the repo file carries the PARKED credits code and must NOT reach prod until `PROD_CREDITS_NEXT_STEPS.md` runs.** Prior: **SECOND PREDICTION RESOLUTION RUN + DRIFT-EMAIL PER-AXIS VIEW + FOLLOWBUTTON ON THE LEDGER** [[project_prediction_methodology_v1]] [[project_scoring_model_v2]]. The 07-08 agent-run resolution sweep scored **all 21 due triggers** [incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers from the 07-07 run, cleared on recheck]. **`/track-record` now shows resolved 35 (was 15), fired 9 (was 4), Brier 0.157 (was 0.084)**; calibration buckets `0-20%`→stated ~17%/fired 9% (n11) · `20-40%`→stated ~20%/fired 9% (n11) · `60-80%`→stated ~65%/fired 54% (n13). Brier rose on purpose: run-1 was flattered by easy low-probability tails; run-2 added real high-stakes calls with genuine misses — the honest direction. The 65% bucket runs ~10pts hot (54% actual) but the curve is monotonic (9%→9%→54%). Cadence confirmed weekly agent-in-session. Prior (07-08, both already on main): **drift-alert email → per-axis view** [`0ec494f`, `SCORING_MODEL_V2_PLAN.md` §12] — `newsDriftCorrector` now embeds `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel` onto each new `DRIFT#`/`DRIFTLOG#` country note; `renderDriftEmail.js` renders a degradation ladder (currentDimensions → Level B 4-axis scorecard `HIGH · Humanitarian 82` + `⚠ N/4 elevated` → changeDimensions → Level A per-axis pills → legacy scalar); not retroactive. **FollowButton surfaced on the `/track-record` corrections ledger** [`b455ee1`, `FOLLOW_SURFACING_PLAN.md`] — the member country-follow control (previously CountryPage-only) now also renders as a sibling of the name link on each **country-scope** ledger row, the highest-intent \"get the next change emailed\" moment; management still lives at `/account?tab=notifications`. Deferred surfaces: CountryWhatChanged band (redundant), ThreadPage (multi-country ambiguity), countries-index (nesting/grid). Prior: **FIRST PREDICTION RESOLUTION RUN DONE + G6 SPORTS-SCOPE GATE LIVE** [[project_prediction_methodology_v1]]. The accountability record is no longer empty: the first agent-run resolution loop (`predictions/V1_RESOLUTION_RUNBOOK.md`) scored **16 of 21 due triggers** — 3 parallel blind web-grounded verifiers → 2nd independent blind pass on every `fired` → operator ~10% spot-check → dry-run → `resolve-v1-write.js --commit`. **`/track-record` now shows Brier 0.084**, resolved 15 (fired 4 · not_fired 11) + 1 unclear, calibration buckets 0-20%→0% (n6) · 20-40%→0% (n4) · 60-80%→80% (n5) [caveat: n tiny, 11/15 were low-p tails correctly not firing = easy points]; 5 triggers deferred (window still open on run day — Le Pen ruling ×3, INDOPACOM, Andersen). Deadlines arrived earlier than the ~07-11 estimate (short-horizon triggers). **New capture gate G6** [`NewsProjectInvokeAgentLambda/src/lib.js`, deployed to `-dev` + deployed-bytes verified; source `91e069e` + deploy `73e490d`]: drops **pure sporting-result** triggers — a match/tournament outcome or on-field participation carries no geopolitical/economic/institutional signal (the remit). Rule = drop only when BOTH a sporting-competition **context** regex AND a **result/participation** regex match, so sport-**adjacent** governance triggers survive (no result verb: \"Belgium files a FIFA protest\", \"CAS grants an interim measure\" pass). **Corpus-validated: across all 1038 live v1 triggers G6 drops exactly the 2 Trump–FIFA/Balogun box-score triggers, 0 false positives**; `node --test` 21/21. NOT retroactive (already-published sport triggers stay scored); scope decision was fix-forward at the trigger layer, NOT an ingest ban. Methodology disclosure live on `/track-record`. Cadence = weekly agent-in-session. Prior: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek v4-pro** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=100`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check · **G6 capture-scope — drops pure sporting results, added 2026-07-07**) before it can enter the immutable log — a malformed or out-of-scope trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. G6 fires only when a trigger has BOTH a sporting-competition context AND a result/participation verb (so sport-adjacent governance triggers survive); corpus-validated 2/1038, 0 false positives. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (21/21, uses the real 2026-07-04-pilot defect triggers + the 2026-07-07 Balogun box-score triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. **Two agent-run resolution sweeps done (07-07, 07-08) → live: resolved 35, fired 9, Brier 0.157, calibration buckets populated** (was honest-empty before 07-07) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered. Country-note fields: `changeScore`/`changeLevel`/`changeDimensions{axis:{from,to,delta}}` (what moved) + `priorHeadline`/`currentHeadline` + `triggerEvent`/`whyChanged`/`noSingleDriver`, **and (scoring-v2 Phase D-email, 2026-07-08) the current standing `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel`** for the email's Level-B scorecard (the vector was already loaded for `changeDimensions`; not backfilled — new notes only).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**⚠️ DEPLOYED ≠ REPO (2026-07-10):** prod still runs the **pre-credits 06-16 code** (gate = `ANALYZE_DAILY_CAP=50`) with the **P1 professional-structure SYSTEM_PROMPT patched in** (2026-07-10: downloaded the deployed zip, patched ONLY the prompt block, redeployed; `MAX_TOKENS` env 1600→2400 for the longer structured output). The server-pinned prompt is now mirrored **verbatim** from the frontend's `utils/analysisPrompt.js` — it had silently drifted (missing all 06-13 upgrades, so member runs got a weaker analyst than BYOK). **Re-synced again 2026-07-10 (`4193610`)** with the **gp-struct visual-block reliability fix** (embedded JSON template + emission mandate — raised valid-block emission on the DeepSeek member path from 1/4 to 8/8, so the ScenarioBars/etc. now actually render for members); the lens-task tail reminders reach the model via the frontend-built `body.user` (the Lambda only holds `SYSTEM_PROMPT`), so syncing that array was sufficient. Same patched-zip method: `FE===BE` verified byte-for-byte (5278 chars). **Never zip the repo `src/index.js` to prod until `PROD_CREDITS_NEXT_STEPS.md` runs** — it carries the parked credits code, and deploying it without the env-ordering steps can mis-grant orders. **Model switch + timeout fix (2026-07-10, prod-config only):** the member-path model was switched `deepseek-chat` (V4 Flash) → **`deepseek-v4-pro`** (env `GROK_MODEL`, the bake-off's stronger analyst), with a required code patch (same patched-deployed-zip method) sending `thinking:{type:'disabled'}` in `runDeepSeek` — v4-pro defaults to thinking mode, which without this burns the whole `MAX_TOKENS` budget on `reasoning_content` and returns **empty output** (`len=0, finish_reason=length`, proven live before the fix; code patch deployed before the env flip so there was no broken window). `ANALYZE_DAILY_CAP` raised 50→100 and `Timeout` raised 30s→120s (v4-pro takes ~40–60s; the old 30s timeout surfaced in-browser as a fake-CORS **\"Failed to fetch\" / \"No 'Access-Control-Allow-Origin' header\"** error — the Function-URL CORS config was correctly empty throughout, so this was NOT the dual-CORS trap: on a synchronous Function-URL Lambda, a timed-out invocation returns a bare platform error with no CORS headers, which reads as CORS but is actually a timeout).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live). **Per-axis view — scoring-v2 Phase D-email (2026-07-08, `SCORING_MODEL_V2_PLAN.md` §12):** `renderDriftEmail` renders a degradation ladder — `note.currentDimensions` → **Level B** compact scorecard (`HIGH · Humanitarian 82` + 4-axis row + `⚠ N/4 elevated`); else `changeDimensions` → **Level A** per-axis change pills; else the legacy scalar. No frontend-util import (reads the note's derived headline fields; breadth flag inline). Not retroactive — Level B only for notes written after the corrector deploy.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d, in-scope — G6 drops pure sporting results from 2026-07-07) and gated-out triggers are recorded in `capture.dropped` with their `gate`; a scenario with no surviving dated trigger carries `scoreable:false`. `finalVerdict`/`confirmedBy:'agent-verified'` written by the Phase-2 agent loop (first run 2026-07-07). Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G6** (`src/lib.js`; G6 = sporting-result scope, added 2026-07-07) → structured, forward-dated, in-scope, `methodologyVersion:1` triggers; malformed/out-of-scope ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4, runbook `predictions/V1_RESOLUTION_RUNBOOK.md`): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface. **Two runs done (weekly cadence):** 07-07 scored 16 of 21 due triggers (5 deferred, window still open on run day); **07-08 scored all 21 due triggers** (incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers, cleared on recheck). Same-day-deadline learning: verifiers need a \"defer\" option when the event window hasn't closed (not yet folded into the runbook doc). Live totals: resolved 35, fired 9, Brier 0.157 (up from 0.084 after run 1 — run 2 added real high-stakes calls with genuine misses, the honest direction; the 60-80% bucket runs ~10pts hot but the curve stays monotonic).\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n5. **Feedback (Phase 5, DESIGNED 2026-07-08 — data-gated, NOT built)** — `CALIBRATION_DIGEST_PLAN.md`: a weekly `newsCalibrationDigest` Lambda will compact the resolved-trigger trace into one ~200-token `CALIBRATION#DIGEST` row (probability-bucket calibration + per-reference-class fire rates + deterministic bias lines); generation GetItems it and injects a `calibrationBlock` into `buildPredictionPrompt`, stamping `methodologyVersion:2` (the flat-65% v1 era stays as control group). Build gates: CP-1 manual measure at ~50 resolved; CP-2 infra at ~150 resolved. Honesty rule: feedback changes what the model says NEXT, never remaps stored/displayed probabilities. Digest rows must NOT carry `methodologyVersion` (the extract script and `prediction_track_record` filter on it).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. **Corrections ledger now also renders `FollowButton` on country-scope rows** (2026-07-08, `FOLLOW_SURFACING_PLAN.md`, `b455ee1`) — sibling of the name link; `FollowButton` was CountryPage-only until now. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). **07-09:** accepts `?stories=<id>[,…]` preselect (seeds once on topics-load; unknown ids dropped) — deep-linked from Home per-story \"Analyze →\", ThreadPage \"Analyze this arc\", `/economy`. **07-10:** output is ICD-203-structured (Key Judgments + yardstick + confidence axis + indicators table); the trailing `gp-struct` JSON block is extracted by `utils/analysisStruct.js` (validator/Markdown get stripped prose) and rendered as `atoms/AnalysisVisuals.jsx` **ScenarioBars/IndicatorMatrix/RippleTable** (struct null → prose-only honest fallback). **07-10 (later, `4193610`):** prompt fix raised DeepSeek's `gp-struct` emission 1/4→8/8 so the visuals render reliably; **Alibaba Qwen** added to the provider list with a workspace base-URL override for `sk-ws-` keys. |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter / **Alibaba Qwen**) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Qwen (added 2026-07-10)** — flagship `qwen3.7-max` (+`qwen3-max`/`qwen3.7-plus`), `enable_thinking:false` (same non-streaming reason as DeepSeek V4); its keyHint/comment flag that Alibaba **workspace-scoped `sk-ws-…` keys** 401 the default `dashscope-intl` host and need the WorkspaceId-specific `…maas.aliyuncs.com` host → handled by `allowBaseUrlOverride` + the optional \"Endpoint URL\" field in `ProviderModal` (persisted in `byok.js`, applied via `runChat({baseUrl})`→`baseUrl||provider.baseUrl`; every other provider unchanged when blank). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Professional structure (2026-07-10, `ANALYSIS_PRO_STRUCTURE_PLAN.md`):** prompts upgraded to ICD-203/desk-note packaging — Key Judgments box (BLUF, each judgment = yardstick probability term + separate justified confidence), the ICD-203 probability yardstick verbatim, an `| Indicator to watch | Confirms | Kills |` table on the scenario lens, and a `gp-struct` machine-readable block → ScenarioBars/IndicatorMatrix/RippleTable visuals (a chart can only show numbers the prose already states; every rule carries an omit-when-unsupported escape hatch — the 06-13 structure-pressure-induces-fabrication lesson). Server prompt (`newsAnalyze`) synced verbatim; guided maxTokens 1600→2400.\n- **Surfacing (2026-07-09, `ANALYZE_SURFACING_PLAN.md`):** was ONE nav link → now Home per-story \"Analyze →\" pill + hero line, ThreadPage \"Analyze this arc →\", `/economy` cross-link, all deep-linking via `?stories=` preselect.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "d3d0be947c22a34ed460453f61c2e310915f53d5": {"t": 1785765794.2319486, "n": 165717, "h": "643a08078c0aa08d", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-10 (refresh: **ANALYSIS STUDIO — VISUAL-BLOCK RELIABILITY FIX + QWEN PROVIDER** [[project_analysis_studio]]. 07-10 [`4193610`, `QWEN_AND_VISUAL_BLOCK_PLAN.md`]: a **qwen3.7-max vs deepseek-v4-pro bake-off** (same stories/lenses, shipped pipeline) exposed the real \"where's the graph?\" cause — DeepSeek (the member-path model) emitted the `gp-struct` block in only **1/4** runs (writes the scenario prose but skips the machine-readable block), so the 07-10 ScenarioBars/IndicatorMatrix/RippleTable rarely rendered for members; Qwen emitted 4/4. **Fix (prompt-only, `analysisPrompt.js`):** embedded a fully-filled JSON template in SYSTEM_PROMPT + a mandatory tail reminder inside each lens task (where the model finishes) + removed the easy \"omit the whole block\" exit for scenario/economic lenses → valid-block emission **1/4 → 8/8** live (`scratchpad/emit-test.mjs`); honesty intact — `validateStruct` still strips any figure not literally in the prose, so all 8 validated with zero fabrication. `newsAnalyze` Lambda re-synced via the **patched-deployed-zip** method (Node made deployed SYSTEM_PROMPT byte-equal to the frontend, `FE===BE` 5278 chars; deployed bytes verified: template present, omit-exit gone, `creditBalance`=0, `MAX_TOKENS`=2400; lens-task tail reminders ride in via the frontend-built `body.user`). **Bake-off verdict:** DeepSeek stays the member-path default (tighter/MECE probability bands, deeper prose, always states confidence); **Qwen3.7-max added as a faster (~36%) BYOK option** — but Alibaba's international keys are **WORKSPACE-scoped (`sk-ws-…`)** and 401 the generic `dashscope-intl` host, needing `https://<WorkspaceId>.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1` (Tokyo; verified with `ws-gl08109galyxzzh3`), so a per-provider **base-URL override** was added (`allowBaseUrlOverride` on qwen; `runChat`/`runOpenAICompat` take optional `baseUrl`→`baseUrl||provider.baseUrl`; ProviderModal optional \"Endpoint URL\" field; `byok.js` persists it back-compat) — live-tested end-to-end through real `runChat` (override→200, default→401). Prior: **ANALYSIS STUDIO SURFACED + PROFESSIONAL STRUCTURE**. 07-09 [`8cf2b29`, `ANALYZE_SURFACING_PLAN.md`]: `/analyze` was discoverable from ONE nav link (~2 runs/30d = discovery gap, not quality — engine live-tested 0/3 fabrication, and a manual web truth-check found every auditor flag was a FALSE POSITIVE vs ground truth); surfaced via a per-story **\"Analyze →\"** pill on Home (4th control beside Summary/Predict/Trace), a Home hero value-prop line, ThreadPage **\"Analyze this arc →\"** (deep-links `thread.entries[0].topicId`), an `/economy` cross-link, and a new **`?stories=<id>[,…]` preselect** in `AnalysisStudio.jsx` (seeds once on topics-load, unknown ids silently dropped). 07-10 [`c429931`, `ANALYSIS_PRO_STRUCTURE_PLAN.md`]: output upgraded to ICD-203/desk-note packaging — **Key Judgments box** (BLUF bullets), the **ICD-203 probability yardstick** verbatim (\"very likely (80–95%)\"), **confidence as a separate justified axis**, a consolidated **`| Indicator to watch | Confirms | Kills |` table** on the scenario lens, and a fenced **`gp-struct` JSON block** (a machine-readable INDEX of the prose — never new content) parsed by new `utils/analysisStruct.js` (truncation-safe; drops scenarios whose probability digits aren't literally in the prose) → rendered by `atoms/AnalysisVisuals.jsx` (**ScenarioBars/IndicatorMatrix/RippleTable**, pure CSS). Validator + Markdown always receive the STRIPPED prose. Review caught 4 pre-deploy bugs: `newsAnalyze`'s server-pinned prompt had **DRIFTED** (missing all 06-13 upgrades — members got a weaker analyst than BYOK; now synced verbatim), gp-struct truncation at 1600 tokens (guided maxTokens→2400, Studio+eval+Lambda env), and two validator false-positives from the new structure (comparison-operator thresholds `>50%`; scenario-heading ranges — estimative look-behind 32→48; goldens added). **⚠️ prod `newsAnalyze` deploy = PROMPT-PATCH of the downloaded DEPLOYED (pre-credits 06-16) zip — the repo file carries the PARKED credits code and must NOT reach prod until `PROD_CREDITS_NEXT_STEPS.md` runs.** Prior: **SECOND PREDICTION RESOLUTION RUN + DRIFT-EMAIL PER-AXIS VIEW + FOLLOWBUTTON ON THE LEDGER** [[project_prediction_methodology_v1]] [[project_scoring_model_v2]]. The 07-08 agent-run resolution sweep scored **all 21 due triggers** [incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers from the 07-07 run, cleared on recheck]. **`/track-record` now shows resolved 35 (was 15), fired 9 (was 4), Brier 0.157 (was 0.084)**; calibration buckets `0-20%`→stated ~17%/fired 9% (n11) · `20-40%`→stated ~20%/fired 9% (n11) · `60-80%`→stated ~65%/fired 54% (n13). Brier rose on purpose: run-1 was flattered by easy low-probability tails; run-2 added real high-stakes calls with genuine misses — the honest direction. The 65% bucket runs ~10pts hot (54% actual) but the curve is monotonic (9%→9%→54%). Cadence confirmed weekly agent-in-session. Prior (07-08, both already on main): **drift-alert email → per-axis view** [`0ec494f`, `SCORING_MODEL_V2_PLAN.md` §12] — `newsDriftCorrector` now embeds `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel` onto each new `DRIFT#`/`DRIFTLOG#` country note; `renderDriftEmail.js` renders a degradation ladder (currentDimensions → Level B 4-axis scorecard `HIGH · Humanitarian 82` + `⚠ N/4 elevated` → changeDimensions → Level A per-axis pills → legacy scalar); not retroactive. **FollowButton surfaced on the `/track-record` corrections ledger** [`b455ee1`, `FOLLOW_SURFACING_PLAN.md`] — the member country-follow control (previously CountryPage-only) now also renders as a sibling of the name link on each **country-scope** ledger row, the highest-intent \"get the next change emailed\" moment; management still lives at `/account?tab=notifications`. Deferred surfaces: CountryWhatChanged band (redundant), ThreadPage (multi-country ambiguity), countries-index (nesting/grid). Prior: **FIRST PREDICTION RESOLUTION RUN DONE + G6 SPORTS-SCOPE GATE LIVE** [[project_prediction_methodology_v1]]. The accountability record is no longer empty: the first agent-run resolution loop (`predictions/V1_RESOLUTION_RUNBOOK.md`) scored **16 of 21 due triggers** — 3 parallel blind web-grounded verifiers → 2nd independent blind pass on every `fired` → operator ~10% spot-check → dry-run → `resolve-v1-write.js --commit`. **`/track-record` now shows Brier 0.084**, resolved 15 (fired 4 · not_fired 11) + 1 unclear, calibration buckets 0-20%→0% (n6) · 20-40%→0% (n4) · 60-80%→80% (n5) [caveat: n tiny, 11/15 were low-p tails correctly not firing = easy points]; 5 triggers deferred (window still open on run day — Le Pen ruling ×3, INDOPACOM, Andersen). Deadlines arrived earlier than the ~07-11 estimate (short-horizon triggers). **New capture gate G6** [`NewsProjectInvokeAgentLambda/src/lib.js`, deployed to `-dev` + deployed-bytes verified; source `91e069e` + deploy `73e490d`]: drops **pure sporting-result** triggers — a match/tournament outcome or on-field participation carries no geopolitical/economic/institutional signal (the remit). Rule = drop only when BOTH a sporting-competition **context** regex AND a **result/participation** regex match, so sport-**adjacent** governance triggers survive (no result verb: \"Belgium files a FIFA protest\", \"CAS grants an interim measure\" pass). **Corpus-validated: across all 1038 live v1 triggers G6 drops exactly the 2 Trump–FIFA/Balogun box-score triggers, 0 false positives**; `node --test` 21/21. NOT retroactive (already-published sport triggers stay scored); scope decision was fix-forward at the trigger layer, NOT an ingest ban. Methodology disclosure live on `/track-record`. Cadence = weekly agent-in-session. Prior: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check · **G6 capture-scope — drops pure sporting results, added 2026-07-07**) before it can enter the immutable log — a malformed or out-of-scope trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. G6 fires only when a trigger has BOTH a sporting-competition context AND a result/participation verb (so sport-adjacent governance triggers survive); corpus-validated 2/1038, 0 false positives. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (21/21, uses the real 2026-07-04-pilot defect triggers + the 2026-07-07 Balogun box-score triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. **Two agent-run resolution sweeps done (07-07, 07-08) → live: resolved 35, fired 9, Brier 0.157, calibration buckets populated** (was honest-empty before 07-07) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered. Country-note fields: `changeScore`/`changeLevel`/`changeDimensions{axis:{from,to,delta}}` (what moved) + `priorHeadline`/`currentHeadline` + `triggerEvent`/`whyChanged`/`noSingleDriver`, **and (scoring-v2 Phase D-email, 2026-07-08) the current standing `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel`** for the email's Level-B scorecard (the vector was already loaded for `changeDimensions`; not backfilled — new notes only).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**⚠️ DEPLOYED ≠ REPO (2026-07-10):** prod still runs the **pre-credits 06-16 code** (gate = `ANALYZE_DAILY_CAP=50`) with the **P1 professional-structure SYSTEM_PROMPT patched in** (2026-07-10: downloaded the deployed zip, patched ONLY the prompt block, redeployed; `MAX_TOKENS` env 1600→2400 for the longer structured output). The server-pinned prompt is now mirrored **verbatim** from the frontend's `utils/analysisPrompt.js` — it had silently drifted (missing all 06-13 upgrades, so member runs got a weaker analyst than BYOK). **Re-synced again 2026-07-10 (`4193610`)** with the **gp-struct visual-block reliability fix** (embedded JSON template + emission mandate — raised valid-block emission on the DeepSeek member path from 1/4 to 8/8, so the ScenarioBars/etc. now actually render for members); the lens-task tail reminders reach the model via the frontend-built `body.user` (the Lambda only holds `SYSTEM_PROMPT`), so syncing that array was sufficient. Same patched-zip method: `FE===BE` verified byte-for-byte (5278 chars). **Never zip the repo `src/index.js` to prod until `PROD_CREDITS_NEXT_STEPS.md` runs** — it carries the parked credits code, and deploying it without the env-ordering steps can mis-grant orders.\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live). **Per-axis view — scoring-v2 Phase D-email (2026-07-08, `SCORING_MODEL_V2_PLAN.md` §12):** `renderDriftEmail` renders a degradation ladder — `note.currentDimensions` → **Level B** compact scorecard (`HIGH · Humanitarian 82` + 4-axis row + `⚠ N/4 elevated`); else `changeDimensions` → **Level A** per-axis change pills; else the legacy scalar. No frontend-util import (reads the note's derived headline fields; breadth flag inline). Not retroactive — Level B only for notes written after the corrector deploy.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d, in-scope — G6 drops pure sporting results from 2026-07-07) and gated-out triggers are recorded in `capture.dropped` with their `gate`; a scenario with no surviving dated trigger carries `scoreable:false`. `finalVerdict`/`confirmedBy:'agent-verified'` written by the Phase-2 agent loop (first run 2026-07-07). Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G6** (`src/lib.js`; G6 = sporting-result scope, added 2026-07-07) → structured, forward-dated, in-scope, `methodologyVersion:1` triggers; malformed/out-of-scope ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4, runbook `predictions/V1_RESOLUTION_RUNBOOK.md`): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface. **Two runs done (weekly cadence):** 07-07 scored 16 of 21 due triggers (5 deferred, window still open on run day); **07-08 scored all 21 due triggers** (incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers, cleared on recheck). Same-day-deadline learning: verifiers need a \"defer\" option when the event window hasn't closed (not yet folded into the runbook doc). Live totals: resolved 35, fired 9, Brier 0.157 (up from 0.084 after run 1 — run 2 added real high-stakes calls with genuine misses, the honest direction; the 60-80% bucket runs ~10pts hot but the curve stays monotonic).\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n5. **Feedback (Phase 5, DESIGNED 2026-07-08 — data-gated, NOT built)** — `CALIBRATION_DIGEST_PLAN.md`: a weekly `newsCalibrationDigest` Lambda will compact the resolved-trigger trace into one ~200-token `CALIBRATION#DIGEST` row (probability-bucket calibration + per-reference-class fire rates + deterministic bias lines); generation GetItems it and injects a `calibrationBlock` into `buildPredictionPrompt`, stamping `methodologyVersion:2` (the flat-65% v1 era stays as control group). Build gates: CP-1 manual measure at ~50 resolved; CP-2 infra at ~150 resolved. Honesty rule: feedback changes what the model says NEXT, never remaps stored/displayed probabilities. Digest rows must NOT carry `methodologyVersion` (the extract script and `prediction_track_record` filter on it).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. **Corrections ledger now also renders `FollowButton` on country-scope rows** (2026-07-08, `FOLLOW_SURFACING_PLAN.md`, `b455ee1`) — sibling of the name link; `FollowButton` was CountryPage-only until now. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). **07-09:** accepts `?stories=<id>[,…]` preselect (seeds once on topics-load; unknown ids dropped) — deep-linked from Home per-story \"Analyze →\", ThreadPage \"Analyze this arc\", `/economy`. **07-10:** output is ICD-203-structured (Key Judgments + yardstick + confidence axis + indicators table); the trailing `gp-struct` JSON block is extracted by `utils/analysisStruct.js` (validator/Markdown get stripped prose) and rendered as `atoms/AnalysisVisuals.jsx` **ScenarioBars/IndicatorMatrix/RippleTable** (struct null → prose-only honest fallback). **07-10 (later, `4193610`):** prompt fix raised DeepSeek's `gp-struct` emission 1/4→8/8 so the visuals render reliably; **Alibaba Qwen** added to the provider list with a workspace base-URL override for `sk-ws-` keys. |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter / **Alibaba Qwen**) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Qwen (added 2026-07-10)** — flagship `qwen3.7-max` (+`qwen3-max`/`qwen3.7-plus`), `enable_thinking:false` (same non-streaming reason as DeepSeek V4); its keyHint/comment flag that Alibaba **workspace-scoped `sk-ws-…` keys** 401 the default `dashscope-intl` host and need the WorkspaceId-specific `…maas.aliyuncs.com` host → handled by `allowBaseUrlOverride` + the optional \"Endpoint URL\" field in `ProviderModal` (persisted in `byok.js`, applied via `runChat({baseUrl})`→`baseUrl||provider.baseUrl`; every other provider unchanged when blank). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Professional structure (2026-07-10, `ANALYSIS_PRO_STRUCTURE_PLAN.md`):** prompts upgraded to ICD-203/desk-note packaging — Key Judgments box (BLUF, each judgment = yardstick probability term + separate justified confidence), the ICD-203 probability yardstick verbatim, an `| Indicator to watch | Confirms | Kills |` table on the scenario lens, and a `gp-struct` machine-readable block → ScenarioBars/IndicatorMatrix/RippleTable visuals (a chart can only show numbers the prose already states; every rule carries an omit-when-unsupported escape hatch — the 06-13 structure-pressure-induces-fabrication lesson). Server prompt (`newsAnalyze`) synced verbatim; guided maxTokens 1600→2400.\n- **Surfacing (2026-07-09, `ANALYZE_SURFACING_PLAN.md`):** was ONE nav link → now Home per-story \"Analyze →\" pill + hero line, ThreadPage \"Analyze this arc →\", `/economy` cross-link, all deep-linking via `?stories=` preselect.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "3dbab7618bae45dfdcba24f76396153e68fe0339": {"t": 1785765795.8882887, "n": 162393, "h": "7ea33f27b5bdf725", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-10 (refresh: **ANALYSIS STUDIO SURFACED + PROFESSIONAL STRUCTURE** [[project_analysis_studio]]. 07-09 [`8cf2b29`, `ANALYZE_SURFACING_PLAN.md`]: `/analyze` was discoverable from ONE nav link (~2 runs/30d = discovery gap, not quality — engine live-tested 0/3 fabrication, and a manual web truth-check found every auditor flag was a FALSE POSITIVE vs ground truth); surfaced via a per-story **\"Analyze →\"** pill on Home (4th control beside Summary/Predict/Trace), a Home hero value-prop line, ThreadPage **\"Analyze this arc →\"** (deep-links `thread.entries[0].topicId`), an `/economy` cross-link, and a new **`?stories=<id>[,…]` preselect** in `AnalysisStudio.jsx` (seeds once on topics-load, unknown ids silently dropped). 07-10 [`c429931`, `ANALYSIS_PRO_STRUCTURE_PLAN.md`]: output upgraded to ICD-203/desk-note packaging — **Key Judgments box** (BLUF bullets), the **ICD-203 probability yardstick** verbatim (\"very likely (80–95%)\"), **confidence as a separate justified axis**, a consolidated **`| Indicator to watch | Confirms | Kills |` table** on the scenario lens, and a fenced **`gp-struct` JSON block** (a machine-readable INDEX of the prose — never new content) parsed by new `utils/analysisStruct.js` (truncation-safe; drops scenarios whose probability digits aren't literally in the prose) → rendered by `atoms/AnalysisVisuals.jsx` (**ScenarioBars/IndicatorMatrix/RippleTable**, pure CSS). Validator + Markdown always receive the STRIPPED prose. Review caught 4 pre-deploy bugs: `newsAnalyze`'s server-pinned prompt had **DRIFTED** (missing all 06-13 upgrades — members got a weaker analyst than BYOK; now synced verbatim), gp-struct truncation at 1600 tokens (guided maxTokens→2400, Studio+eval+Lambda env), and two validator false-positives from the new structure (comparison-operator thresholds `>50%`; scenario-heading ranges — estimative look-behind 32→48; goldens added). **⚠️ prod `newsAnalyze` deploy = PROMPT-PATCH of the downloaded DEPLOYED (pre-credits 06-16) zip — the repo file carries the PARKED credits code and must NOT reach prod until `PROD_CREDITS_NEXT_STEPS.md` runs.** Prior: **SECOND PREDICTION RESOLUTION RUN + DRIFT-EMAIL PER-AXIS VIEW + FOLLOWBUTTON ON THE LEDGER** [[project_prediction_methodology_v1]] [[project_scoring_model_v2]]. The 07-08 agent-run resolution sweep scored **all 21 due triggers** [incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers from the 07-07 run, cleared on recheck]. **`/track-record` now shows resolved 35 (was 15), fired 9 (was 4), Brier 0.157 (was 0.084)**; calibration buckets `0-20%`→stated ~17%/fired 9% (n11) · `20-40%`→stated ~20%/fired 9% (n11) · `60-80%`→stated ~65%/fired 54% (n13). Brier rose on purpose: run-1 was flattered by easy low-probability tails; run-2 added real high-stakes calls with genuine misses — the honest direction. The 65% bucket runs ~10pts hot (54% actual) but the curve is monotonic (9%→9%→54%). Cadence confirmed weekly agent-in-session. Prior (07-08, both already on main): **drift-alert email → per-axis view** [`0ec494f`, `SCORING_MODEL_V2_PLAN.md` §12] — `newsDriftCorrector` now embeds `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel` onto each new `DRIFT#`/`DRIFTLOG#` country note; `renderDriftEmail.js` renders a degradation ladder (currentDimensions → Level B 4-axis scorecard `HIGH · Humanitarian 82` + `⚠ N/4 elevated` → changeDimensions → Level A per-axis pills → legacy scalar); not retroactive. **FollowButton surfaced on the `/track-record` corrections ledger** [`b455ee1`, `FOLLOW_SURFACING_PLAN.md`] — the member country-follow control (previously CountryPage-only) now also renders as a sibling of the name link on each **country-scope** ledger row, the highest-intent \"get the next change emailed\" moment; management still lives at `/account?tab=notifications`. Deferred surfaces: CountryWhatChanged band (redundant), ThreadPage (multi-country ambiguity), countries-index (nesting/grid). Prior: **FIRST PREDICTION RESOLUTION RUN DONE + G6 SPORTS-SCOPE GATE LIVE** [[project_prediction_methodology_v1]]. The accountability record is no longer empty: the first agent-run resolution loop (`predictions/V1_RESOLUTION_RUNBOOK.md`) scored **16 of 21 due triggers** — 3 parallel blind web-grounded verifiers → 2nd independent blind pass on every `fired` → operator ~10% spot-check → dry-run → `resolve-v1-write.js --commit`. **`/track-record` now shows Brier 0.084**, resolved 15 (fired 4 · not_fired 11) + 1 unclear, calibration buckets 0-20%→0% (n6) · 20-40%→0% (n4) · 60-80%→80% (n5) [caveat: n tiny, 11/15 were low-p tails correctly not firing = easy points]; 5 triggers deferred (window still open on run day — Le Pen ruling ×3, INDOPACOM, Andersen). Deadlines arrived earlier than the ~07-11 estimate (short-horizon triggers). **New capture gate G6** [`NewsProjectInvokeAgentLambda/src/lib.js`, deployed to `-dev` + deployed-bytes verified; source `91e069e` + deploy `73e490d`]: drops **pure sporting-result** triggers — a match/tournament outcome or on-field participation carries no geopolitical/economic/institutional signal (the remit). Rule = drop only when BOTH a sporting-competition **context** regex AND a **result/participation** regex match, so sport-**adjacent** governance triggers survive (no result verb: \"Belgium files a FIFA protest\", \"CAS grants an interim measure\" pass). **Corpus-validated: across all 1038 live v1 triggers G6 drops exactly the 2 Trump–FIFA/Balogun box-score triggers, 0 false positives**; `node --test` 21/21. NOT retroactive (already-published sport triggers stay scored); scope decision was fix-forward at the trigger layer, NOT an ingest ban. Methodology disclosure live on `/track-record`. Cadence = weekly agent-in-session. Prior: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check · **G6 capture-scope — drops pure sporting results, added 2026-07-07**) before it can enter the immutable log — a malformed or out-of-scope trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. G6 fires only when a trigger has BOTH a sporting-competition context AND a result/participation verb (so sport-adjacent governance triggers survive); corpus-validated 2/1038, 0 false positives. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (21/21, uses the real 2026-07-04-pilot defect triggers + the 2026-07-07 Balogun box-score triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. **Two agent-run resolution sweeps done (07-07, 07-08) → live: resolved 35, fired 9, Brier 0.157, calibration buckets populated** (was honest-empty before 07-07) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered. Country-note fields: `changeScore`/`changeLevel`/`changeDimensions{axis:{from,to,delta}}` (what moved) + `priorHeadline`/`currentHeadline` + `triggerEvent`/`whyChanged`/`noSingleDriver`, **and (scoring-v2 Phase D-email, 2026-07-08) the current standing `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel`** for the email's Level-B scorecard (the vector was already loaded for `changeDimensions`; not backfilled — new notes only).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**⚠️ DEPLOYED ≠ REPO (2026-07-10):** prod still runs the **pre-credits 06-16 code** (gate = `ANALYZE_DAILY_CAP=50`) with the **P1 professional-structure SYSTEM_PROMPT patched in** (2026-07-10: downloaded the deployed zip, patched ONLY the prompt block, redeployed; `MAX_TOKENS` env 1600→2400 for the longer structured output). The server-pinned prompt is now mirrored **verbatim** from the frontend's `utils/analysisPrompt.js` — it had silently drifted (missing all 06-13 upgrades, so member runs got a weaker analyst than BYOK). **Never zip the repo `src/index.js` to prod until `PROD_CREDITS_NEXT_STEPS.md` runs** — it carries the parked credits code, and deploying it without the env-ordering steps can mis-grant orders.\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live). **Per-axis view — scoring-v2 Phase D-email (2026-07-08, `SCORING_MODEL_V2_PLAN.md` §12):** `renderDriftEmail` renders a degradation ladder — `note.currentDimensions` → **Level B** compact scorecard (`HIGH · Humanitarian 82` + 4-axis row + `⚠ N/4 elevated`); else `changeDimensions` → **Level A** per-axis change pills; else the legacy scalar. No frontend-util import (reads the note's derived headline fields; breadth flag inline). Not retroactive — Level B only for notes written after the corrector deploy.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d, in-scope — G6 drops pure sporting results from 2026-07-07) and gated-out triggers are recorded in `capture.dropped` with their `gate`; a scenario with no surviving dated trigger carries `scoreable:false`. `finalVerdict`/`confirmedBy:'agent-verified'` written by the Phase-2 agent loop (first run 2026-07-07). Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G6** (`src/lib.js`; G6 = sporting-result scope, added 2026-07-07) → structured, forward-dated, in-scope, `methodologyVersion:1` triggers; malformed/out-of-scope ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4, runbook `predictions/V1_RESOLUTION_RUNBOOK.md`): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface. **Two runs done (weekly cadence):** 07-07 scored 16 of 21 due triggers (5 deferred, window still open on run day); **07-08 scored all 21 due triggers** (incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers, cleared on recheck). Same-day-deadline learning: verifiers need a \"defer\" option when the event window hasn't closed (not yet folded into the runbook doc). Live totals: resolved 35, fired 9, Brier 0.157 (up from 0.084 after run 1 — run 2 added real high-stakes calls with genuine misses, the honest direction; the 60-80% bucket runs ~10pts hot but the curve stays monotonic).\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n5. **Feedback (Phase 5, DESIGNED 2026-07-08 — data-gated, NOT built)** — `CALIBRATION_DIGEST_PLAN.md`: a weekly `newsCalibrationDigest` Lambda will compact the resolved-trigger trace into one ~200-token `CALIBRATION#DIGEST` row (probability-bucket calibration + per-reference-class fire rates + deterministic bias lines); generation GetItems it and injects a `calibrationBlock` into `buildPredictionPrompt`, stamping `methodologyVersion:2` (the flat-65% v1 era stays as control group). Build gates: CP-1 manual measure at ~50 resolved; CP-2 infra at ~150 resolved. Honesty rule: feedback changes what the model says NEXT, never remaps stored/displayed probabilities. Digest rows must NOT carry `methodologyVersion` (the extract script and `prediction_track_record` filter on it).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. **Corrections ledger now also renders `FollowButton` on country-scope rows** (2026-07-08, `FOLLOW_SURFACING_PLAN.md`, `b455ee1`) — sibling of the name link; `FollowButton` was CountryPage-only until now. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). **07-09:** accepts `?stories=<id>[,…]` preselect (seeds once on topics-load; unknown ids dropped) — deep-linked from Home per-story \"Analyze →\", ThreadPage \"Analyze this arc\", `/economy`. **07-10:** output is ICD-203-structured (Key Judgments + yardstick + confidence axis + indicators table); the trailing `gp-struct` JSON block is extracted by `utils/analysisStruct.js` (validator/Markdown get stripped prose) and rendered as `atoms/AnalysisVisuals.jsx` **ScenarioBars/IndicatorMatrix/RippleTable** (struct null → prose-only honest fallback). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Professional structure (2026-07-10, `ANALYSIS_PRO_STRUCTURE_PLAN.md`):** prompts upgraded to ICD-203/desk-note packaging — Key Judgments box (BLUF, each judgment = yardstick probability term + separate justified confidence), the ICD-203 probability yardstick verbatim, an `| Indicator to watch | Confirms | Kills |` table on the scenario lens, and a `gp-struct` machine-readable block → ScenarioBars/IndicatorMatrix/RippleTable visuals (a chart can only show numbers the prose already states; every rule carries an omit-when-unsupported escape hatch — the 06-13 structure-pressure-induces-fabrication lesson). Server prompt (`newsAnalyze`) synced verbatim; guided maxTokens 1600→2400.\n- **Surfacing (2026-07-09, `ANALYZE_SURFACING_PLAN.md`):** was ONE nav link → now Home per-story \"Analyze →\" pill + hero line, ThreadPage \"Analyze this arc →\", `/economy` cross-link, all deep-linking via `?stories=` preselect.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "41b1fb987456d9063507f4a942568383a5631eef": {"t": 1785765797.8949418, "n": 158113, "h": "fd198ec3c9bbbc7f", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-08 (refresh: **SECOND PREDICTION RESOLUTION RUN + DRIFT-EMAIL PER-AXIS VIEW + FOLLOWBUTTON ON THE LEDGER** [[project_prediction_methodology_v1]] [[project_scoring_model_v2]]. The 07-08 agent-run resolution sweep scored **all 21 due triggers** [incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers from the 07-07 run, cleared on recheck]. **`/track-record` now shows resolved 35 (was 15), fired 9 (was 4), Brier 0.157 (was 0.084)**; calibration buckets `0-20%`→stated ~17%/fired 9% (n11) · `20-40%`→stated ~20%/fired 9% (n11) · `60-80%`→stated ~65%/fired 54% (n13). Brier rose on purpose: run-1 was flattered by easy low-probability tails; run-2 added real high-stakes calls with genuine misses — the honest direction. The 65% bucket runs ~10pts hot (54% actual) but the curve is monotonic (9%→9%→54%). Cadence confirmed weekly agent-in-session. Prior (07-08, both already on main): **drift-alert email → per-axis view** [`0ec494f`, `SCORING_MODEL_V2_PLAN.md` §12] — `newsDriftCorrector` now embeds `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel` onto each new `DRIFT#`/`DRIFTLOG#` country note; `renderDriftEmail.js` renders a degradation ladder (currentDimensions → Level B 4-axis scorecard `HIGH · Humanitarian 82` + `⚠ N/4 elevated` → changeDimensions → Level A per-axis pills → legacy scalar); not retroactive. **FollowButton surfaced on the `/track-record` corrections ledger** [`b455ee1`, `FOLLOW_SURFACING_PLAN.md`] — the member country-follow control (previously CountryPage-only) now also renders as a sibling of the name link on each **country-scope** ledger row, the highest-intent \"get the next change emailed\" moment; management still lives at `/account?tab=notifications`. Deferred surfaces: CountryWhatChanged band (redundant), ThreadPage (multi-country ambiguity), countries-index (nesting/grid). Prior: **FIRST PREDICTION RESOLUTION RUN DONE + G6 SPORTS-SCOPE GATE LIVE** [[project_prediction_methodology_v1]]. The accountability record is no longer empty: the first agent-run resolution loop (`predictions/V1_RESOLUTION_RUNBOOK.md`) scored **16 of 21 due triggers** — 3 parallel blind web-grounded verifiers → 2nd independent blind pass on every `fired` → operator ~10% spot-check → dry-run → `resolve-v1-write.js --commit`. **`/track-record` now shows Brier 0.084**, resolved 15 (fired 4 · not_fired 11) + 1 unclear, calibration buckets 0-20%→0% (n6) · 20-40%→0% (n4) · 60-80%→80% (n5) [caveat: n tiny, 11/15 were low-p tails correctly not firing = easy points]; 5 triggers deferred (window still open on run day — Le Pen ruling ×3, INDOPACOM, Andersen). Deadlines arrived earlier than the ~07-11 estimate (short-horizon triggers). **New capture gate G6** [`NewsProjectInvokeAgentLambda/src/lib.js`, deployed to `-dev` + deployed-bytes verified; source `91e069e` + deploy `73e490d`]: drops **pure sporting-result** triggers — a match/tournament outcome or on-field participation carries no geopolitical/economic/institutional signal (the remit). Rule = drop only when BOTH a sporting-competition **context** regex AND a **result/participation** regex match, so sport-**adjacent** governance triggers survive (no result verb: \"Belgium files a FIFA protest\", \"CAS grants an interim measure\" pass). **Corpus-validated: across all 1038 live v1 triggers G6 drops exactly the 2 Trump–FIFA/Balogun box-score triggers, 0 false positives**; `node --test` 21/21. NOT retroactive (already-published sport triggers stay scored); scope decision was fix-forward at the trigger layer, NOT an ingest ban. Methodology disclosure live on `/track-record`. Cadence = weekly agent-in-session. Prior: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check · **G6 capture-scope — drops pure sporting results, added 2026-07-07**) before it can enter the immutable log — a malformed or out-of-scope trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. G6 fires only when a trigger has BOTH a sporting-competition context AND a result/participation verb (so sport-adjacent governance triggers survive); corpus-validated 2/1038, 0 false positives. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (21/21, uses the real 2026-07-04-pilot defect triggers + the 2026-07-07 Balogun box-score triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. **Two agent-run resolution sweeps done (07-07, 07-08) → live: resolved 35, fired 9, Brier 0.157, calibration buckets populated** (was honest-empty before 07-07) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered. Country-note fields: `changeScore`/`changeLevel`/`changeDimensions{axis:{from,to,delta}}` (what moved) + `priorHeadline`/`currentHeadline` + `triggerEvent`/`whyChanged`/`noSingleDriver`, **and (scoring-v2 Phase D-email, 2026-07-08) the current standing `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel`** for the email's Level-B scorecard (the vector was already loaded for `changeDimensions`; not backfilled — new notes only).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live). **Per-axis view — scoring-v2 Phase D-email (2026-07-08, `SCORING_MODEL_V2_PLAN.md` §12):** `renderDriftEmail` renders a degradation ladder — `note.currentDimensions` → **Level B** compact scorecard (`HIGH · Humanitarian 82` + 4-axis row + `⚠ N/4 elevated`); else `changeDimensions` → **Level A** per-axis change pills; else the legacy scalar. No frontend-util import (reads the note's derived headline fields; breadth flag inline). Not retroactive — Level B only for notes written after the corrector deploy.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d, in-scope — G6 drops pure sporting results from 2026-07-07) and gated-out triggers are recorded in `capture.dropped` with their `gate`; a scenario with no surviving dated trigger carries `scoreable:false`. `finalVerdict`/`confirmedBy:'agent-verified'` written by the Phase-2 agent loop (first run 2026-07-07). Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G6** (`src/lib.js`; G6 = sporting-result scope, added 2026-07-07) → structured, forward-dated, in-scope, `methodologyVersion:1` triggers; malformed/out-of-scope ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4, runbook `predictions/V1_RESOLUTION_RUNBOOK.md`): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface. **Two runs done (weekly cadence):** 07-07 scored 16 of 21 due triggers (5 deferred, window still open on run day); **07-08 scored all 21 due triggers** (incl. the Le Pen appeal verdict + the 2 same-day-deferred triggers, cleared on recheck). Same-day-deadline learning: verifiers need a \"defer\" option when the event window hasn't closed (not yet folded into the runbook doc). Live totals: resolved 35, fired 9, Brier 0.157 (up from 0.084 after run 1 — run 2 added real high-stakes calls with genuine misses, the honest direction; the 60-80% bucket runs ~10pts hot but the curve stays monotonic).\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n5. **Feedback (Phase 5, DESIGNED 2026-07-08 — data-gated, NOT built)** — `CALIBRATION_DIGEST_PLAN.md`: a weekly `newsCalibrationDigest` Lambda will compact the resolved-trigger trace into one ~200-token `CALIBRATION#DIGEST` row (probability-bucket calibration + per-reference-class fire rates + deterministic bias lines); generation GetItems it and injects a `calibrationBlock` into `buildPredictionPrompt`, stamping `methodologyVersion:2` (the flat-65% v1 era stays as control group). Build gates: CP-1 manual measure at ~50 resolved; CP-2 infra at ~150 resolved. Honesty rule: feedback changes what the model says NEXT, never remaps stored/displayed probabilities. Digest rows must NOT carry `methodologyVersion` (the extract script and `prediction_track_record` filter on it).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. **Corrections ledger now also renders `FollowButton` on country-scope rows** (2026-07-08, `FOLLOW_SURFACING_PLAN.md`, `b455ee1`) — sibling of the name link; `FollowButton` was CountryPage-only until now. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "663017fd644043ac234cac69a3987f6a1f59b2d0": {"t": 1785765799.802903, "n": 155688, "h": "226f6f787419e373", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-07 (refresh: **FIRST PREDICTION RESOLUTION RUN DONE + G6 SPORTS-SCOPE GATE LIVE** [[project_prediction_methodology_v1]]. The accountability record is no longer empty: the first agent-run resolution loop (`predictions/V1_RESOLUTION_RUNBOOK.md`) scored **16 of 21 due triggers** — 3 parallel blind web-grounded verifiers → 2nd independent blind pass on every `fired` → operator ~10% spot-check → dry-run → `resolve-v1-write.js --commit`. **`/track-record` now shows Brier 0.084**, resolved 15 (fired 4 · not_fired 11) + 1 unclear, calibration buckets 0-20%→0% (n6) · 20-40%→0% (n4) · 60-80%→80% (n5) [caveat: n tiny, 11/15 were low-p tails correctly not firing = easy points]; 5 triggers deferred (window still open on run day — Le Pen ruling ×3, INDOPACOM, Andersen). Deadlines arrived earlier than the ~07-11 estimate (short-horizon triggers). **New capture gate G6** [`NewsProjectInvokeAgentLambda/src/lib.js`, deployed to `-dev` + deployed-bytes verified; source `91e069e` + deploy `73e490d`]: drops **pure sporting-result** triggers — a match/tournament outcome or on-field participation carries no geopolitical/economic/institutional signal (the remit). Rule = drop only when BOTH a sporting-competition **context** regex AND a **result/participation** regex match, so sport-**adjacent** governance triggers survive (no result verb: \"Belgium files a FIFA protest\", \"CAS grants an interim measure\" pass). **Corpus-validated: across all 1038 live v1 triggers G6 drops exactly the 2 Trump–FIFA/Balogun box-score triggers, 0 false positives**; `node --test` 21/21. NOT retroactive (already-published sport triggers stay scored); scope decision was fix-forward at the trigger layer, NOT an ingest ban. Methodology disclosure live on `/track-record`. Cadence = weekly agent-in-session. Prior: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check · **G6 capture-scope — drops pure sporting results, added 2026-07-07**) before it can enter the immutable log — a malformed or out-of-scope trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. G6 fires only when a trigger has BOTH a sporting-competition context AND a result/participation verb (so sport-adjacent governance triggers survive); corpus-validated 2/1038, 0 false positives. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (21/21, uses the real 2026-07-04-pilot defect triggers + the 2026-07-07 Balogun box-score triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. **First resolution run 2026-07-07 → live: resolved 15, Brier 0.084, calibration buckets populated** (was honest-empty before) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered. Country-note fields: `changeScore`/`changeLevel`/`changeDimensions{axis:{from,to,delta}}` (what moved) + `priorHeadline`/`currentHeadline` + `triggerEvent`/`whyChanged`/`noSingleDriver`, **and (scoring-v2 Phase D-email, 2026-07-08) the current standing `currentDimensions`/`currentLead`/`currentRiskScore`/`currentRiskLevel`** for the email's Level-B scorecard (the vector was already loaded for `changeDimensions`; not backfilled — new notes only).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live). **Per-axis view — scoring-v2 Phase D-email (2026-07-08, `SCORING_MODEL_V2_PLAN.md` §12):** `renderDriftEmail` renders a degradation ladder — `note.currentDimensions` → **Level B** compact scorecard (`HIGH · Humanitarian 82` + 4-axis row + `⚠ N/4 elevated`); else `changeDimensions` → **Level A** per-axis change pills; else the legacy scalar. No frontend-util import (reads the note's derived headline fields; breadth flag inline). Not retroactive — Level B only for notes written after the corrector deploy.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d, in-scope — G6 drops pure sporting results from 2026-07-07) and gated-out triggers are recorded in `capture.dropped` with their `gate`; a scenario with no surviving dated trigger carries `scoreable:false`. `finalVerdict`/`confirmedBy:'agent-verified'` written by the Phase-2 agent loop (first run 2026-07-07). Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G6** (`src/lib.js`; G6 = sporting-result scope, added 2026-07-07) → structured, forward-dated, in-scope, `methodologyVersion:1` triggers; malformed/out-of-scope ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4, runbook `predictions/V1_RESOLUTION_RUNBOOK.md`): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface. **First run DONE 2026-07-07** (weekly cadence): 16 of 21 due triggers scored, 5 deferred (window still open on run day). Same-day-deadline learning: verifiers need a \"defer\" option when the event window hasn't closed (not yet folded into the runbook doc).\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n5. **Feedback (Phase 5, DESIGNED 2026-07-08 — data-gated, NOT built)** — `CALIBRATION_DIGEST_PLAN.md`: a weekly `newsCalibrationDigest` Lambda will compact the resolved-trigger trace into one ~200-token `CALIBRATION#DIGEST` row (probability-bucket calibration + per-reference-class fire rates + deterministic bias lines); generation GetItems it and injects a `calibrationBlock` into `buildPredictionPrompt`, stamping `methodologyVersion:2` (the flat-65% v1 era stays as control group). Build gates: CP-1 manual measure at ~50 resolved; CP-2 infra at ~150 resolved. Honesty rule: feedback changes what the model says NEXT, never remaps stored/displayed probabilities. Digest rows must NOT carry `methodologyVersion` (the extract script and `prediction_track_record` filter on it).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "8717d246d7fb2786a85d8dfa35136e897eb9037d": {"t": 1785765801.8279965, "n": 154729, "h": "eb7b844a0f1a819b", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-07 (refresh: **FIRST PREDICTION RESOLUTION RUN DONE + G6 SPORTS-SCOPE GATE LIVE** [[project_prediction_methodology_v1]]. The accountability record is no longer empty: the first agent-run resolution loop (`predictions/V1_RESOLUTION_RUNBOOK.md`) scored **16 of 21 due triggers** — 3 parallel blind web-grounded verifiers → 2nd independent blind pass on every `fired` → operator ~10% spot-check → dry-run → `resolve-v1-write.js --commit`. **`/track-record` now shows Brier 0.084**, resolved 15 (fired 4 · not_fired 11) + 1 unclear, calibration buckets 0-20%→0% (n6) · 20-40%→0% (n4) · 60-80%→80% (n5) [caveat: n tiny, 11/15 were low-p tails correctly not firing = easy points]; 5 triggers deferred (window still open on run day — Le Pen ruling ×3, INDOPACOM, Andersen). Deadlines arrived earlier than the ~07-11 estimate (short-horizon triggers). **New capture gate G6** [`NewsProjectInvokeAgentLambda/src/lib.js`, deployed to `-dev` + deployed-bytes verified; source `91e069e` + deploy `73e490d`]: drops **pure sporting-result** triggers — a match/tournament outcome or on-field participation carries no geopolitical/economic/institutional signal (the remit). Rule = drop only when BOTH a sporting-competition **context** regex AND a **result/participation** regex match, so sport-**adjacent** governance triggers survive (no result verb: \"Belgium files a FIFA protest\", \"CAS grants an interim measure\" pass). **Corpus-validated: across all 1038 live v1 triggers G6 drops exactly the 2 Trump–FIFA/Balogun box-score triggers, 0 false positives**; `node --test` 21/21. NOT retroactive (already-published sport triggers stay scored); scope decision was fix-forward at the trigger layer, NOT an ingest ban. Methodology disclosure live on `/track-record`. Cadence = weekly agent-in-session. Prior: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check · **G6 capture-scope — drops pure sporting results, added 2026-07-07**) before it can enter the immutable log — a malformed or out-of-scope trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. G6 fires only when a trigger has BOTH a sporting-competition context AND a result/participation verb (so sport-adjacent governance triggers survive); corpus-validated 2/1038, 0 false positives. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (21/21, uses the real 2026-07-04-pilot defect triggers + the 2026-07-07 Balogun box-score triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. **First resolution run 2026-07-07 → live: resolved 15, Brier 0.084, calibration buckets populated** (was honest-empty before) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered.\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live).\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d, in-scope — G6 drops pure sporting results from 2026-07-07) and gated-out triggers are recorded in `capture.dropped` with their `gate`; a scenario with no surviving dated trigger carries `scoreable:false`. `finalVerdict`/`confirmedBy:'agent-verified'` written by the Phase-2 agent loop (first run 2026-07-07). Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G6** (`src/lib.js`; G6 = sporting-result scope, added 2026-07-07) → structured, forward-dated, in-scope, `methodologyVersion:1` triggers; malformed/out-of-scope ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4, runbook `predictions/V1_RESOLUTION_RUNBOOK.md`): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface. **First run DONE 2026-07-07** (weekly cadence): 16 of 21 due triggers scored, 5 deferred (window still open on run day). Same-day-deadline learning: verifiers need a \"defer\" option when the event window hasn't closed (not yet folded into the runbook doc).\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n5. **Feedback (Phase 5, DESIGNED 2026-07-08 — data-gated, NOT built)** — `CALIBRATION_DIGEST_PLAN.md`: a weekly `newsCalibrationDigest` Lambda will compact the resolved-trigger trace into one ~200-token `CALIBRATION#DIGEST` row (probability-bucket calibration + per-reference-class fire rates + deterministic bias lines); generation GetItems it and injects a `calibrationBlock` into `buildPredictionPrompt`, stamping `methodologyVersion:2` (the flat-65% v1 era stays as control group). Build gates: CP-1 manual measure at ~50 resolved; CP-2 infra at ~150 resolved. Honesty rule: feedback changes what the model says NEXT, never remaps stored/displayed probabilities. Digest rows must NOT carry `methodologyVersion` (the extract script and `prediction_track_record` filter on it).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "2673453a83e13f22f04a13a613bd79a8cd4bd0c6": {"t": 1785765803.617079, "n": 153948, "h": "cb94009a96010033", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-07 (refresh: **FIRST PREDICTION RESOLUTION RUN DONE + G6 SPORTS-SCOPE GATE LIVE** [[project_prediction_methodology_v1]]. The accountability record is no longer empty: the first agent-run resolution loop (`predictions/V1_RESOLUTION_RUNBOOK.md`) scored **16 of 21 due triggers** — 3 parallel blind web-grounded verifiers → 2nd independent blind pass on every `fired` → operator ~10% spot-check → dry-run → `resolve-v1-write.js --commit`. **`/track-record` now shows Brier 0.084**, resolved 15 (fired 4 · not_fired 11) + 1 unclear, calibration buckets 0-20%→0% (n6) · 20-40%→0% (n4) · 60-80%→80% (n5) [caveat: n tiny, 11/15 were low-p tails correctly not firing = easy points]; 5 triggers deferred (window still open on run day — Le Pen ruling ×3, INDOPACOM, Andersen). Deadlines arrived earlier than the ~07-11 estimate (short-horizon triggers). **New capture gate G6** [`NewsProjectInvokeAgentLambda/src/lib.js`, deployed to `-dev` + deployed-bytes verified; source `91e069e` + deploy `73e490d`]: drops **pure sporting-result** triggers — a match/tournament outcome or on-field participation carries no geopolitical/economic/institutional signal (the remit). Rule = drop only when BOTH a sporting-competition **context** regex AND a **result/participation** regex match, so sport-**adjacent** governance triggers survive (no result verb: \"Belgium files a FIFA protest\", \"CAS grants an interim measure\" pass). **Corpus-validated: across all 1038 live v1 triggers G6 drops exactly the 2 Trump–FIFA/Balogun box-score triggers, 0 false positives**; `node --test` 21/21. NOT retroactive (already-published sport triggers stay scored); scope decision was fix-forward at the trigger layer, NOT an ingest ban. Methodology disclosure live on `/track-record`. Cadence = weekly agent-in-session. Prior: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check · **G6 capture-scope — drops pure sporting results, added 2026-07-07**) before it can enter the immutable log — a malformed or out-of-scope trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. G6 fires only when a trigger has BOTH a sporting-competition context AND a result/participation verb (so sport-adjacent governance triggers survive); corpus-validated 2/1038, 0 false positives. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (21/21, uses the real 2026-07-04-pilot defect triggers + the 2026-07-07 Balogun box-score triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. **First resolution run 2026-07-07 → live: resolved 15, Brier 0.084, calibration buckets populated** (was honest-empty before) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered.\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live).\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d, in-scope — G6 drops pure sporting results from 2026-07-07) and gated-out triggers are recorded in `capture.dropped` with their `gate`; a scenario with no surviving dated trigger carries `scoreable:false`. `finalVerdict`/`confirmedBy:'agent-verified'` written by the Phase-2 agent loop (first run 2026-07-07). Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G6** (`src/lib.js`; G6 = sporting-result scope, added 2026-07-07) → structured, forward-dated, in-scope, `methodologyVersion:1` triggers; malformed/out-of-scope ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4, runbook `predictions/V1_RESOLUTION_RUNBOOK.md`): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface. **First run DONE 2026-07-07** (weekly cadence): 16 of 21 due triggers scored, 5 deferred (window still open on run day). Same-day-deadline learning: verifiers need a \"defer\" option when the event window hasn't closed (not yet folded into the runbook doc).\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "d5314b352878cf44820237e1fe2994f7ffd2bea5": {"t": 1785765805.892049, "n": 151265, "h": "7b6d781497de68f6", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-07 (refresh: **MEMBER AUTO-FIX GATING LIVE** [`MEMBER_GATING_PLAN.md` — merged to main `f3e0f4d`, prod-verified]: membership now buys the **depth + alerts** of the self-correcting-analysis layer, while every analysis page + the *latest* correction + the whole track record stay **free** — **data-cap, never a hard block** (anon always 200 → the 2026-04-22 anon-guard regression can't recur). **P1 depth-cap:** `newsSensitiveData-dev` `resolveTier` (best-effort Firebase-JWT → `GlobalPerspectiveUserTable.tier`) + pure `capForTier` (`src/lib.js`, `node --test`) → `country_history` caps `driftNotes`→newest 1 + `driftNotesTotal`/`driftNotesGated`; `corrections_feed` caps→newest 5 + `total`/`gated`; members get the full chain [prod: anon Ukraine 1/6 gated, ledger 5/26 gated, 51 snapshots uncapped]. **P2** lock affordances [`CountryWhatChanged` + `/track-record` — \"🔒 N earlier — Join\", reads server `gated`/`total`, no client tier check]. **P3 permanent archive:** `newsDriftCorrector` now writes each note TWICE via `putDriftNote` — `DRIFT#<date>` (60d TTL) + **`DRIFTLOG#<date>` (NO TTL)** so member history outlives 60d; `country_history` unions archive+live via pure `dedupeByAsOf` [tests 9/9; empty archive ⇒ no-op; `begins_with('DRIFT#')` excludes `DRIFTLOG#` → no double-count]. **P5 follow-country change-alerts** [the member perk]: `newsRecommend` `followedCountries` (String Set, atomic ADD/DELETE) + `driftOptIn`; `follow_country`/`unfollow_country` = signed-in AND member-tier [`getTier` fail-closed → `403 members_only`; **new IAM `ReadTier` GetItem on `GlobalPerspectiveUserTable` added to `newsRecommend-ddb`** — required or nobody can follow]; `handleUnsubscribe` kind=`drift`; `newsEmailSender` `mode:'drift_alert'` (`runDrift` + `renderDriftEmail.js`, mirrors `runBreaking` emailedAt idempotency); frontend `FollowButton` [member 🔔 toggle / non-member locked `/membership` CTA] + `/account` \"Country change-alerts\" list. **⚠️ drift email OFF by design:** `TriggerDriftEmailSend` created **DISABLED** [`cron(40 7 * * ? *)` → `{mode:drift_alert}`; sender is `DRY_RUN=false`=live → send-to-self smoke test before enabling; 0 drift subscribers today]. **P4** membership copy repositioned across 6 spots [old \"buys compute, not access\" → \"reading free; membership adds depth\"]. Deploy: no function needs bundled `node_modules` (runtime `@aws-sdk` + node `crypto`); frontend bundle `index-DRIay5Lv.js`; single ACAO verified. [[project_member_gating]]. Prior: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check) before it can enter the immutable log — a malformed trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (uses the real 2026-07-04-pilot defect triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | Optional (tier cap) | `{ countryName }` | Historical archive entries for a country: `snapshots[]` (always full/public) + `driftNotes[]` (the correction chain). **Member-gated depth (2026-07-07):** unions live `DRIFT#` + permanent `DRIFTLOG#` via `dedupeByAsOf`, then non-members get the newest **1** + `driftNotesTotal`/`driftNotesGated:true`; members get the full chain (`resolveTier`+`capForTier`) |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. Honest empty state until v1 verdicts exist (~2026-07-11) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | Optional (tier cap) | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. **Member-gated depth (2026-07-07):** non-members get the newest **5** + `total` + `gated:true`; members get the full list (`resolveTier`+`capForTier`). Deferred optimization: a single `CORRECTIONS_FEED` GetItem row. Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03; `kind=drift` added 2026-07-07) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|drift|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`/`driftOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n6. **Follow-country actions** (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `follow_country`/`unfollow_country` (signed-in **AND member-tier**; `getTier` GetItem on `GlobalPerspectiveUserTable` — **needs the `ReadTier` statement added to `newsRecommend-ddb`** — fail-closed → `403 members_only`). Atomic `ADD`/`DELETE` on the `followedCountries` **String Set** on the user's `GlobalPerspectiveUserPrefs` row; a follow also defaults `driftOptIn=true` + stamps consent/token/email = a complete drift-alert subscription. `get_prefs`/`set_prefs` extended to carry `followedCountries`/`driftOptIn`.\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes each note TWICE via `putDriftNote` (idempotent, country + thread): `…/DRIFT#{date}` (`DRIFT_TTL_DAYS=60`, powers the live band + ledger) **and `…/DRIFTLOG#{date}` (NO TTL, the permanent member-history archive — added 2026-07-07, `MEMBER_GATING_PLAN.md` P3)**. Never overwrites; the `alreadyNoted` GetItem guard means a note is written once, so the `emailedAt` stamp `newsEmailSender` adds (drift alerts) is never clobbered.\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts + drift alerts (LIVE 2026-07-04; drift mode added 2026-07-07)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**; **`TriggerDriftEmailSend` — `cron(40 7 * * ? *)` (07:40 UTC, ~20m after the drift corrector) created DISABLED** (send-to-self smoke test before go-live). All invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n3. `drift_alert` (added 2026-07-07, `MEMBER_GATING_PLAN.md` P5) — `runDrift` scans `SummarizeAndPredict` for fresh **country** `DRIFT#` notes (`begins_with(PK,'COUNTRY#') AND begins_with(SK,'DRIFT#') AND emailedAt unset AND generatedAt ≥ DRIFT_FRESH_HOURS` [default 36]), groups by country, and for each emails the **followers** (`GlobalPerspectiveUserPrefs` where `driftOptIn` + `followedCountries` contains the country) via `renderDriftEmail.js`, then stamps `emailedAt` once (idempotent, mirrors `breaking`). `kind='drift'` unsubscribe. **Cron DISABLED at deploy** (sender is `DRY_RUN=false`=live).\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d) and gated-out triggers are recorded in `capture.dropped`; a scenario with no surviving dated trigger carries `scoreable:false`. Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G5** (`src/lib.js`) → structured, forward-dated, `methodologyVersion:1` triggers; malformed ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod). See Lambda [#2](#2-newsprojectinvokeagentlambda).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface.\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "87fdfc9a93345d3ef7e0f6356c06811512f097d3": {"t": 1785765807.8173904, "n": 146598, "h": "ed2c6566ef9e8a56", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-06 (refresh: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **LIVE — deployed via `wrangler deploy` [version `8b73c9c7`, drift-checked, dashboard routes preserved; recipe in the plan §P4]**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check) before it can enter the immutable log — a malformed trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (uses the real 2026-07-04-pilot defect triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. Honest empty state until v1 verdicts exist (~2026-07-11) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | None | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. Deferred optimization: a single `CORRECTIONS_FEED` GetItem row (have `newsDriftCorrector` append). Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes `COUNTRY#{name}/DRIFT#{date}` + `THREAD#{threadId}/DRIFT#{date}` notes (never overwrites; `DRIFT_TTL_DAYS=60`).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts (LIVE 2026-07-04)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**. Both invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d) and gated-out triggers are recorded in `capture.dropped`; a scenario with no surviving dated trigger carries `scoreable:false`. Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G5** (`src/lib.js`) → structured, forward-dated, `methodologyVersion:1` triggers; malformed ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod). See Lambda [#2](#2-newsprojectinvokeagentlambda).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface.\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "a943bd97a7bb48e362e0ba95ade3da07ddb7c7eb": {"t": 1785765809.5623457, "n": 146536, "h": "8d22245f85987962", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-06 (refresh: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **code-ready in `WORKER_FULL_CODE.md` — operator CF-paste pending**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check) before it can enter the immutable log — a malformed trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (uses the real 2026-07-04-pilot defect triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. Honest empty state until v1 verdicts exist (~2026-07-11) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | None | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. Deferred optimization: a single `CORRECTIONS_FEED` GetItem row (have `newsDriftCorrector` append). Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes `COUNTRY#{name}/DRIFT#{date}` + `THREAD#{threadId}/DRIFT#{date}` notes (never overwrites; `DRIFT_TTL_DAYS=60`).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts (LIVE 2026-07-04)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**. Both invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d) and gated-out triggers are recorded in `capture.dropped`; a scenario with no surviving dated trigger carries `scoreable:false`. Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G5** (`src/lib.js`) → structured, forward-dated, `methodologyVersion:1` triggers; malformed ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod). See Lambda [#2](#2-newsprojectinvokeagentlambda).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface.\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | **LIVE 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P4b, deployed via `wrangler deploy`, version `8b73c9c7`): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache, `x-rendered-by: cf-worker-bot`. Previously bots/AI crawlers got the empty SPA shell at the most-linked URL. Deploy recipe in the plan doc §P4 (wrangler with `workers_dev=false` + no routes preserves the dashboard routes) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "9fabfe3c28e5394a3692e4175c3693a76b7f88ac": {"t": 1785765811.267647, "n": 146449, "h": "c684164928b87a7a", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-06 (refresh: **SITE ORIENTATION SHIPPED** [`SITE_ORIENTATION_PLAN.md` — deploys `729dc9e` + `72baef1`, prod-verified]: the diagnosis was a feature-surplus with an **orientation gap**, not a missing landing page — so NO new pages. (P1) Home masthead now states the accountability value prop [links `/track-record`] + an **honest-data trust strip** above `SubscribeCard` [3 stat cards from `useTrackRecord`/`useCorrectionsFeed`/today's sources; each card omitted when its number is absent; live 815/26/34]; (P2) the 9-item nav is **grouped** [Briefings · Intelligence · Markets/Analysis · Accountability] with hairline dividers + a per-item `title` tooltip [canonical page one-liners in the plan doc]; (P3) trust-chain cross-links [ThreadForecast→`/track-record`, TrackRecord→`/weekly` live boards, CountryWhatChanged→`/track-record`]; (P4) `index.html` meta/OG/FAQ-schema carry the accountability positioning + **fixed the noscript `/pricing` dead link** [deleted route — crawlers 404'd] → `/track-record`+`/membership`; the Worker `/` bot branch [`renderRootPage()`, static, 24h cache] is **code-ready in `WORKER_FULL_CODE.md` — operator CF-paste pending**; (P5) pairs cleanup **CANCELLED on evidence**: `pair_analyses_list` is live-consumed by the `/map` arc overlays [`WorldMapV2`→`usePairAnalyses`] — the old \"unconsumed\" note here was stale and is corrected at Lambda #8. Prior: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check) before it can enter the immutable log — a malformed trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (uses the real 2026-07-04-pilot defect triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. Honest empty state until v1 verdicts exist (~2026-07-11) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | None | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. Deferred optimization: a single `CORRECTIONS_FEED` GetItem row (have `newsDriftCorrector` append). Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes `COUNTRY#{name}/DRIFT#{date}` + `THREAD#{threadId}/DRIFT#{date}` notes (never overwrites; `DRIFT_TTL_DAYS=60`).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts (LIVE 2026-07-04)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**. Both invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d) and gated-out triggers are recorded in `capture.dropped`; a scenario with no surviving dated trigger carries `scoreable:false`. Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G5** (`src/lib.js`) → structured, forward-dated, `methodologyVersion:1` triggers; malformed ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod). See Lambda [#2](#2-newsprojectinvokeagentlambda).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface.\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/` | Bot (24 patterns) | ⚠ **code-ready, operator paste pending** (2026-07-06, `SITE_ORIENTATION_PLAN.md` P4b): static `renderRootPage()` — accountability value prop + grouped page directory, no Lambda call, 24h edge cache. In `WORKER_FULL_CODE.md`; NOT yet deployed to the live Worker — until pasted, bots get the SPA shell (mitigated by the enriched `index.html` meta, live) |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)). **Nav grouped 2026-07-06** (`SITE_ORIENTATION_PLAN.md` P2): Briefings (Topics/Daily/Weekly Brief) · Intelligence (Threads/Countries/Map) · Markets/Analysis (Economy/Analyze) · Accountability (Track Record), hairline `.gp-nav-div` dividers between groups, every item carries a `title` tooltip describing its job (canonical one-liners in the plan doc) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then a masthead whose sub-line states the **accountability value prop** (links `/track-record`; 2026-07-06), a **trust strip** (3 stat cards — dated forecast triggers via `useTrackRecord`, corrections logged via `useCorrectionsFeed`, sources/stories today; each card omitted when its number is absent — honest-empty) above `SubscribeCard`, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`; each note links to its source country/thread page) + published methodology. Trust-chain links added 2026-07-06: forecast-record foot → `/weekly` live boards; inbound links from `ThreadForecast` + `CountryWhatChanged` + the Home masthead/trust strip. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "72baef1205bcd761361433d9dff83054852119c2": {"t": 1785765813.427764, "n": 143781, "h": "08d3c0406b4c25d7", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-06 (refresh: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check) before it can enter the immutable log — a malformed trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (uses the real 2026-07-04-pilot defect triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. Honest empty state until v1 verdicts exist (~2026-07-11) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | None | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. Deferred optimization: a single `CORRECTIONS_FEED` GetItem row (have `newsDriftCorrector` append). Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** the dedicated pair pages (PairListPage/PairPage, `/weekly/pairs` + `/weekly/pair/:slug`) were deleted in the \"Cut: orphans\" cleanup — but **`pair_analyses_list` is LIVE-consumed by `/map`** (verified in code 2026-07-06): `WorldMapV2.jsx` → `usePairAnalyses` renders the cross-country **arc overlays** (bilateral links, 7d/30d time-window filters, legend counts, selected-country link list). Do NOT remove it.\n**API actions:** `pair_analyses_list` (all pairs, DDB Scan) — **consumed by WorldMapV2 arc overlays**; `pair_analysis` (single pair by slug) — served but unconsumed (`fetchPairAnalysis` in restProxy has no callers); kept as a harmless read-only action\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes `COUNTRY#{name}/DRIFT#{date}` + `THREAD#{threadId}/DRIFT#{date}` notes (never overwrites; `DRIFT_TTL_DAYS=60`).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts (LIVE 2026-07-04)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**. Both invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d) and gated-out triggers are recorded in `capture.dropped`; a scenario with no surviving dated trigger carries `scoreable:false`. Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G5** (`src/lib.js`) → structured, forward-dated, `methodologyVersion:1` triggers; malformed ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod). See Lambda [#2](#2-newsprojectinvokeagentlambda).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface.\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`) + published methodology. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "f2af4abbcdccc1c3887e7e7849b0d10ed6a75473": {"t": 1785765815.2249594, "n": 143472, "h": "f02c232297a6bdc2", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-06 (refresh: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. **Phase 4 SHIPPED 2026-07-06** [`d53e40a` source + `6192fcb` deploy]: **\"Living forecast\" board on ThreadPage** — the new `prediction_snapshot` action returns a thread's newest v1 `PRED#` snapshot [scenarios + dated triggers]; `ThreadForecast` renders it at the top of the Arc Intelligence rail as a trigger checklist [`☐` open · `✓` fired · `~~✗~~` not_fired · `⌛` awaiting], pending today and striking/checking as verdicts land. Wiring proven: 55/145 active threads carry a v1 forecast. **All four phases now shipped**; Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first live resolution run ~2026-07-11]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check) before it can enter the immutable log — a malformed trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (uses the real 2026-07-04-pilot defect triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. Honest empty state until v1 verdicts exist (~2026-07-11) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | None | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. Deferred optimization: a single `CORRECTIONS_FEED` GetItem row (have `newsDriftCorrector` append). Powers the `/track-record` Accountability hub |\n| `prediction_snapshot` | None | `{topicIds:[…]}` (≤25) | Phase 4 per-thread \"Living forecast\" board. Parallel Query-latest per topic on `GlobalPerspectivePredictionLog`, returns the single NEWEST `methodologyVersion>=1` `PRED#` snapshot among them: `{topicId, title, generatedAt, status, scenarios[{label, probability, triggers[{id, text, deadline, verdict, confirmedBy, citation}]}]}` (dated triggers only). **v1-only** (legacy retrodiction-prone snapshots never surface); `snapshot:null` when none. Powers `ThreadForecast` on ThreadPage |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** none — the PairListPage/PairPage components and their `/weekly/pairs` + `/weekly/pair/:slug` routes were deleted in the \"Cut: orphans\" cleanup. The backend data + API actions remain; nothing renders them.\n**API actions:** `pair_analysis` (single pair by slug) + `pair_analyses_list` (all pairs, DDB Scan) — still served, currently unconsumed by the UI\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes `COUNTRY#{name}/DRIFT#{date}` + `THREAD#{threadId}/DRIFT#{date}` notes (never overwrites; `DRIFT_TTL_DAYS=60`).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts (LIVE 2026-07-04)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**. Both invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d) and gated-out triggers are recorded in `capture.dropped`; a scenario with no surviving dated trigger carries `scoreable:false`. Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Four phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G5** (`src/lib.js`) → structured, forward-dated, `methodologyVersion:1` triggers; malformed ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod). See Lambda [#2](#2-newsprojectinvokeagentlambda).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface.\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n4. **Surface per-thread (Phase 4, SHIPPED 2026-07-06)** — `prediction_snapshot` returns a thread's newest v1 snapshot; `ThreadForecast` (hook `useThreadForecast`) renders it as the **\"Living forecast\" board** at the top of ThreadPage's Arc Intelligence rail — scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Honest-empty when a thread has no v1 forecast. This is the user's original \"living prediction\" view.\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** — now led by the Phase 4 **`ThreadForecast`** \"Living forecast\" board (`useThreadForecast`), then Summary → What's Next → Trace → Watch → Live Web Evidence. De-tabbed + de-duplicated 2026-06-29; forecast board added 2026-07-06 |\n| `ThreadForecast.jsx` | Phase 4 \"Living forecast\" board (in ThreadPage's rail): the thread's newest v1 prediction snapshot as scenarios + a dated-trigger checklist that renders pending now and strikes (`not_fired`) / checks (`fired`) as verdicts land. Presentational (data from `useThreadForecast`); honest-empty when no v1 forecast |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`) + published methodology. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useThreadForecast(topicIds)` | Fetch a thread's newest v1 prediction snapshot (`prediction_snapshot`) for the \"Living forecast\" board; honest-empty (`null`) when none; powers `ThreadForecast` on ThreadPage |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "39d099e1e04ef933f948e4dc6c812910330972cc": {"t": 1785765817.0706317, "n": 141434, "h": "fdf831e3cba9d523", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-05 (refresh: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. **Phase 3 SHIPPED 2026-07-05** [`8f94806` source + `c76fbd9` deploy]: **`/track-record` rebuilt into the \"Accountability\" hub** [kept the URL — reversible] — (1) era-cut forecast scorecard [scores only `methodologyVersion>=1`; `prediction_track_record` now returns `eraCutFrom`/`legacyPredictionsExcluded`; live = 2418 legacy excluded, resolved 0, honest-empty], (2) **corrections ledger** [new `corrections_feed` action Scans DRIFT# across all countries+threads; the living-analysis loop's site-level home; live = 26 notes], (3) published methodology incl. the pre-v1 exclusion disclosure. Phase 2 tooling ready [`predictions/resolve-v1-*.js`; first v1 deadlines ~2026-07-11]; Phase 4 [ThreadPage strikethrough scenario board] pending. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check) before it can enter the immutable log — a malformed trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (uses the real 2026-07-04-pilot defect triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. **Scores ONLY `methodologyVersion>=1` records (the 2026-07-04 era-cut)** — returns `eraCutFrom` + `legacyPredictionsExcluded` (legacy backlog kept but unscored), reads v1 `agentVerdict.evidence` citations. Honest empty state until v1 verdicts exist (~2026-07-11) — see [Prediction calibration](#prediction-calibration-track-record) |\n| `corrections_feed` | None | `{limit?≤100}` | Site-wide corrections ledger (the living-analysis loop's aggregate view): recent grounded `DRIFT#` notes across ALL `COUNTRY#`/`THREAD#` rows, newest-first. DDB Scan filtered on `begins_with(SK,'DRIFT#')` (same pattern as `economic_impact_list`; 60d-TTL rows, client-cached 30min). Each note: `{scope, name, asOf, changeLevel?, changeScore?, triggerEvent?, whyChanged?, noSingleDriver}`. Deferred optimization: a single `CORRECTIONS_FEED` GetItem row (have `newsDriftCorrector` append). Powers the `/track-record` Accountability hub |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** none — the PairListPage/PairPage components and their `/weekly/pairs` + `/weekly/pair/:slug` routes were deleted in the \"Cut: orphans\" cleanup. The backend data + API actions remain; nothing renders them.\n**API actions:** `pair_analysis` (single pair by slug) + `pair_analyses_list` (all pairs, DDB Scan) — still served, currently unconsumed by the UI\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes `COUNTRY#{name}/DRIFT#{date}` + `THREAD#{threadId}/DRIFT#{date}` notes (never overwrites; `DRIFT_TTL_DAYS=60`).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts (LIVE 2026-07-04)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**. Both invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d) and gated-out triggers are recorded in `capture.dropped`; a scenario with no surviving dated trigger carries `scoreable:false`. Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Three phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G5** (`src/lib.js`) → structured, forward-dated, `methodologyVersion:1` triggers; malformed ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod). See Lambda [#2](#2-newsprojectinvokeagentlambda).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface.\n3. **Score + surface (Phase 3, SHIPPED 2026-07-05)** — `prediction_track_record` computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets, and recent resolved list, **scoping to `methodologyVersion>=1`** (era-cut; returns `eraCutFrom`/`legacyPredictionsExcluded`). `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) is now the **\"Accountability\" hub**: (a) the era-cut-framed forecast scorecard with an **honest empty state** — shows nothing rather than a fabricated score until v1 verdicts exist ([[feedback-no-misinformation-fallback]]); (b) a **corrections ledger** (`useCorrectionsFeed` → `corrections_feed`) surfacing recent grounded `DRIFT#` moves across all countries+threads — the living-analysis loop's public log, populated from day one; (c) the **published methodology** incl. the pre-v1 exclusion disclosure. Kept the `/track-record` URL (reversible; alias deferred).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** (Summary → What's Next → Trace → Watch → Live Web Evidence) — de-tabbed + de-duplicated 2026-06-29 (it previously had its own competing tab widget + duplicated key actors + a `DisruptionPreview` that now live only in the center tabs) |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — the **\"Accountability\" hub** (rebuilt Phase 3, 2026-07-05): era-cut forecast scorecard (stat cards, Brier + calibration + recently-resolved once verdicts land; honest empty state until then) + **corrections ledger** (`useCorrectionsFeed`) + published methodology. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`, v1 era-cut); 30min cache; powers `/track-record` |\n| `useCorrectionsFeed(limit)` | Fetch the site-wide corrections ledger (`corrections_feed` — recent `DRIFT#` notes across all countries+threads); 30min localStorage cache (`gp_corrections_feed`); powers the `/track-record` Accountability hub |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "8629ce79736c19596137f5f7e84d6e60ed47ca9c": {"t": 1785765819.0969408, "n": 139237, "h": "d93c62faef1cfb21", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-05 (refresh: **PREDICTION METHODOLOGY v1 LIVE** — the forecast pipeline was rebuilt + **deployed to prod 2026-07-04 (the era-cut date)**. A 50-trigger resolution pilot found the legacy prediction log was structurally unscoreable — **28% retrodictions** [trigger `deadline ≤ generatedAt` → automatic wins → silent Brier inflation], plus false-premise + date-artifact triggers — so the decision was **clean-start**: don't score the legacy backlog, rebuild capture, score only `methodologyVersion:1` records going forward. New `NewsProjectInvokeAgentLambda/src/lib.js` [pure, `node --test` 16/16] holds the **capture gates G1–G5** [date-valid · forward-looking · ≤180d horizon · falsifiability lint · `FACTS#` premise-check]; `logPredictionSnapshot` runs every trigger through `buildGatedScenarios` → structured `{text,deadline}` triggers + a `capture.dropped` report + `methodologyVersion:1` stamp. Generation is now **grounded**: verified `FACTS#` leader premises + the story's own dated **arc digest** + optional Brave web context. **Verified live in prod** [27 v1 snapshots, gates firing: G4 caught relative-window slips, 0 retrodictions]. A real integration bug was caught pre-declare [`generatedDate` is a human string \"July 4, 2026\", not ISO — slicing it broke `addDays`; fixed to UTC-today]. Merged to main [`6a90b14`] + pushed. Next: Phase 2 agent-run resolution loop [first v1 deadlines due ~2026-07-11], Phase 3 accountability page [`/track-record` redirect + corrections ledger]. Plan `PREDICTION_METHODOLOGY_V1_PLAN.md` + worked example `PREDICTION_V1_EXAMPLE.md`; [[project_prediction_methodology_v1]]. Prior: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis + scenarios/triggers with winners/losers (forecast — speculative by design). **Methodology v1 (deployed 2026-07-04, `PREDICTION_METHODOLOGY_V1_PLAN.md`):** the two-pass research→forecast path is now **grounded** — the research pass gets verified `FACTS#` leaders (premise block, prevents false-leader triggers), the story's own **dated arc digest** from our archive (so the forecast builds on the arc, not one day's snippets), and optional live Brave web context (no-op without `BRAVE_SEARCH_API_KEY`). The forecast emits **structured `{text, deadline}` triggers**, and every trigger passes the **capture gates** in `src/lib.js` (G1 date-valid · G2 forward-looking (kills retrodictions) · G3 ≤180d horizon · G4 falsifiability lint · G5 premise-check) before it can enter the immutable log — a malformed trigger is **dropped and recorded** in the snapshot's `capture.dropped`, never scored. See `logPredictionSnapshot` + `lib.buildGatedScenarios`; `test/lib.test.js` is the `node --test` suite (uses the real 2026-07-04-pilot defect triggers). Gate config = pure functions, no LLM/network.\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. Honest empty state until human-confirmed verdicts exist — see [Prediction calibration](#prediction-calibration-track-record) |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** none — the PairListPage/PairPage components and their `/weekly/pairs` + `/weekly/pair/:slug` routes were deleted in the \"Cut: orphans\" cleanup. The backend data + API actions remain; nothing renders them.\n**API actions:** `pair_analysis` (single pair by slug) + `pair_analyses_list` (all pairs, DDB Scan) — still served, currently unconsumed by the UI\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes `COUNTRY#{name}/DRIFT#{date}` + `THREAD#{threadId}/DRIFT#{date}` notes (never overwrites; `DRIFT_TTL_DAYS=60`).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts (LIVE 2026-07-04)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**. Both invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, methodologyVersion?, capture?{methodologyVersion,kept,dropped[{scenario,text,deadline,gate,why}]}, scenarios[{ label, probability(numeric midpoint), scoreable?, triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`. **Methodology v1 (records written from 2026-07-04, `methodologyVersion:1`):** triggers are gate-validated at capture (structured `{text,deadline}`, forward-dated, ≤180d) and gated-out triggers are recorded in `capture.dropped`; a scenario with no surviving dated trigger carries `scoreable:false`. Pre-v1 records (no `methodologyVersion`) are the legacy backlog — kept immutable but **excluded from scoring** (they carry the trigger-generation defects the 2026-07-04 pilot found: retrodictions, false premises, date artifacts). See `PREDICTION_METHODOLOGY_V1_PLAN.md`, [[project_prediction_methodology_v1]].\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n> **Methodology v1 + era cut (2026-07-04).** A 50-trigger resolution pilot found the *legacy* capture was structurally unscoreable — **28% retrodictions** (trigger `deadline ≤ generatedAt` → automatic wins → silent score inflation), plus false-premise + date-artifact triggers. Decision: **do not score the legacy backlog**; rebuild capture (Phase 1 below, deployed 2026-07-04 = the **era-cut date**) and score only `methodologyVersion:1` records going forward. The published methodology says so plainly — \"we found defects in our early triggers and excluded them from scoring rather than cherry-pick\" is a stronger trust signal than a retrofitted Brier number. Full plan: `PREDICTION_METHODOLOGY_V1_PLAN.md`; pilot verdict sheet + self-consistency evidence in [[project_prediction_methodology_v1]].\n\n**Three phases:**\n1. **Capture (v1)** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction`. Grounded generation (verified `FACTS#` premises + archive arc digest + optional Brave) + **capture gates G1–G5** (`src/lib.js`) → structured, forward-dated, `methodologyVersion:1` triggers; malformed ones dropped into `capture.dropped`. Never throws into the pipeline. Verified live 2026-07-04 (gates firing in prod). See Lambda [#2](#2-newsprojectinvokeagentlambda).\n2. **Resolve** — legacy `newsPredictionResolver` (#20) still proposes hint-verdicts, but the pilot showed it punts `unclear` 86% of the time. The v1 loop is **agent-run** (Phase 2, `PREDICTION_METHODOLOGY_V1_PLAN.md` §4): scan due v1 triggers oldest-first, blind web-grounded verification (double-pass on every `fired`), write `finalVerdict` + `confirmedBy:'agent-verified'`; operator blind-spot-checks ~10%. `node predictions/review.js` remains the manual write path. No public auth surface.\n3. **Score + surface** — `prediction_track_record` proxy action computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets (predicted vs actual fired-rate), and recent resolved list. `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) renders it with an **honest empty state** — shows nothing rather than a fabricated score until human-confirmed verdicts exist ([[feedback-no-misinformation-fallback]]).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** (Summary → What's Next → Trace → Watch → Live Web Evidence) — de-tabbed + de-duplicated 2026-06-29 (it previously had its own competing tab widget + duplicated key actors + a `DisruptionPreview` that now live only in the center tabs) |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — forecast calibration scoreboard (stat cards, Brier score + verdict, calibration table, recently-resolved triggers); honest empty state until human-confirmed verdicts exist. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`); 30min cache; powers `/track-record` |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "3cb8d7c2ff74501ed8f8e2649856ca9d7e2ed237": {"t": 1785765821.371538, "n": 134596, "h": "94a28e692eba9e0b", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-04 (refresh: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis with winners/losers (forecast — speculative by design)\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline. **Enriched 2026-07-05 (additive, best-effort):** each situation also carries `riskLevel`/`riskScore` (GetItem on `COUNTRY_INTELLIGENCE`) + `latestDrift {asOf, changeLevel?, changeScore, whyChanged, noSingleDriver}` (newest `DRIFT#` note) — double try/catch, a DDB failure degrades to the unenriched payload; absent fields render honestly (grey bubble / no badge) |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. Honest empty state until human-confirmed verdicts exist — see [Prediction calibration](#prediction-calibration-track-record) |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** none — the PairListPage/PairPage components and their `/weekly/pairs` + `/weekly/pair/:slug` routes were deleted in the \"Cut: orphans\" cleanup. The backend data + API actions remain; nothing renders them.\n**API actions:** `pair_analysis` (single pair by slug) + `pair_analyses_list` (all pairs, DDB Scan) — still served, currently unconsumed by the UI\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes `COUNTRY#{name}/DRIFT#{date}` + `THREAD#{threadId}/DRIFT#{date}` notes (never overwrites; `DRIFT_TTL_DAYS=60`).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts (LIVE 2026-07-04)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**. Both invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, scenarios[{ label, probability(numeric midpoint), triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`.\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n**Three phases:**\n1. **Capture** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction` (scenarios → numeric probability midpoint + dated triggers). Never throws into the pipeline.\n2. **Resolve (hybrid)** — `newsPredictionResolver` (#20, daily 09:00 UTC) **proposes** verdicts grounded in Brave Search; a human **confirms** via `node predictions/review.js` (interactive / `--list` / `--accept-all`). No public auth surface — mirrors the economic-quality human-review pattern.\n3. **Score + surface** — `prediction_track_record` proxy action computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets (predicted vs actual fired-rate), and recent resolved list. `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) renders it with an **honest empty state** — shows nothing rather than a fabricated score until human-confirmed verdicts exist ([[feedback-no-misinformation-fallback]]).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px). Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. **Enhancement pass 2026-07-05 (LIVE):** (1) **shareable URL state** — `?view=country&country=X` (world = bare URL), push navigation so back/forward traverse drills, EconomyPage `lastWritten`-guard pattern; (2) **fit-to-width** — `dayW` compresses (clamped 26–44px) to the measured width so the whole month fits with **no horizontal scroll** either (below the floor the scroll opens at the newest edge); (3) **clickable shared-actor links** — floating top-right card (actors, weight, \"Open X's web →\" drill buttons), 14px transparent hit area; (4) **bubble fill = canonical risk tier** (`riskTiers.js`/`RISK_SOLID`, \"state = tier\" rule; grey = honest no-read; category → tooltip; legend overlay) fed by the enriched `world_overview` action (attaches `riskLevel`/`riskScore` + `latestDrift` per country — additive, best-effort, deployed to `newsSensitiveData-dev` 2026-07-05); (5) **drift badges** — a pulsing rim dot + \"Read changed {date}: from → to\" tooltip when the country's read moved within 7 days (living-analysis loop surfaced on the analyst map); (6) **defensible-edge wording** — every causal-edge surface reads \"**possibly related**,\" never \"caused\" (EdgePanel/tooltip/\"Inferred links\" toggle/desc/footer), per the verified rule that \"caused\" requires corroboration + analyst sign-off (none yet). See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** (Summary → What's Next → Trace → Watch → Live Web Evidence) — de-tabbed + de-duplicated 2026-06-29 (it previously had its own competing tab widget + duplicated key actors + a `DisruptionPreview` that now live only in the center tabs) |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — forecast calibration scoreboard (stat cards, Brier score + verdict, calibration table, recently-resolved triggers); honest empty state until human-confirmed verdicts exist. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`); 30min cache; powers `/track-record` |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "4563417bb4704da263971dc026cdfcb03f1d9b14": {"t": 1785765823.156123, "n": 132967, "h": "44294a9a048d0349", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-04 (refresh: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis with winners/losers (forecast — speculative by design)\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. Honest empty state until human-confirmed verdicts exist — see [Prediction calibration](#prediction-calibration-track-record) |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** none — the PairListPage/PairPage components and their `/weekly/pairs` + `/weekly/pair/:slug` routes were deleted in the \"Cut: orphans\" cleanup. The backend data + API actions remain; nothing renders them.\n**API actions:** `pair_analysis` (single pair by slug) + `pair_analyses_list` (all pairs, DDB Scan) — still served, currently unconsumed by the UI\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed — **shows the past 7 days only**, older alerts dropped, 2026-07-04) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map (latest + \"+N more today\"). This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes `COUNTRY#{name}/DRIFT#{date}` + `THREAD#{threadId}/DRIFT#{date}` notes (never overwrites; `DRIFT_TTL_DAYS=60`).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts (LIVE 2026-07-04)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**. Both invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, scenarios[{ label, probability(numeric midpoint), triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`.\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n**Three phases:**\n1. **Capture** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction` (scenarios → numeric probability midpoint + dated triggers). Never throws into the pipeline.\n2. **Resolve (hybrid)** — `newsPredictionResolver` (#20, daily 09:00 UTC) **proposes** verdicts grounded in Brave Search; a human **confirms** via `node predictions/review.js` (interactive / `--list` / `--accept-all`). No public auth surface — mirrors the economic-quality human-review pattern.\n3. **Score + surface** — `prediction_track_record` proxy action computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets (predicted vs actual fired-rate), and recent resolved list. `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) renders it with an **honest empty state** — shows nothing rather than a fabricated score until human-confirmed verdicts exist ([[feedback-no-misinformation-fallback]]).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px); the date axis still scrolls horizontally. Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** (Summary → What's Next → Trace → Watch → Live Web Evidence) — de-tabbed + de-duplicated 2026-06-29 (it previously had its own competing tab widget + duplicated key actors + a `DisruptionPreview` that now live only in the center tabs) |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — forecast calibration scoreboard (stat cards, Brier score + verdict, calibration table, recently-resolved triggers); honest empty state until human-confirmed verdicts exist. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26; today's-breaking enhancement 2026-07-04). Renders **only** when there's a confirmed alert <24h old — else nothing (no stale/fabricated banner). Surfaces the latest fresh alert's headline **+ a \"+N more today\" pill** counting the other <24h alerts; links to `/breaking` (full feed) when there's more than one, else deep-links to the single `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`); 30min cache; powers `/track-record` |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "b4d90a3960a98228d9439713da7e16189338b06d": {"t": 1785765824.959495, "n": 132650, "h": "5064c399719e1148", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-04 (refresh: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis with winners/losers (forecast — speculative by design)\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. Honest empty state until human-confirmed verdicts exist — see [Prediction calibration](#prediction-calibration-track-record) |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** none — the PairListPage/PairPage components and their `/weekly/pairs` + `/weekly/pair/:slug` routes were deleted in the \"Cut: orphans\" cleanup. The backend data + API actions remain; nothing renders them.\n**API actions:** `pair_analysis` (single pair by slug) + `pair_analyses_list` (all pairs, DDB Scan) — still served, currently unconsumed by the UI\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map. This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes `COUNTRY#{name}/DRIFT#{date}` + `THREAD#{threadId}/DRIFT#{date}` notes (never overwrites; `DRIFT_TTL_DAYS=60`).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts (LIVE 2026-07-04)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**. Both invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, scenarios[{ label, probability(numeric midpoint), triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`.\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n**Three phases:**\n1. **Capture** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction` (scenarios → numeric probability midpoint + dated triggers). Never throws into the pipeline.\n2. **Resolve (hybrid)** — `newsPredictionResolver` (#20, daily 09:00 UTC) **proposes** verdicts grounded in Brave Search; a human **confirms** via `node predictions/review.js` (interactive / `--list` / `--accept-all`). No public auth surface — mirrors the economic-quality human-review pattern.\n3. **Score + surface** — `prediction_track_record` proxy action computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets (predicted vs actual fired-rate), and recent resolved list. `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) renders it with an **honest empty state** — shows nothing rather than a fabricated score until human-confirmed verdicts exist ([[feedback-no-misinformation-fallback]]).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane; bubbles sized by thread count, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. **World-tier readability pass 2026-07-04:** removed the per-country duration span bar (it drew each situation's earliest→latest window at the bubble's y, but with shared region lanes + month-long situations the bars overlapped into one rule that read as a false connector — the active window lives in the hover tooltip now) and the redundant \"peak Jun X\" subtext (already encoded by the bubble's x-position). The World tier now **fits every populated region lane into the viewport with no vertical scroll** — only populated lanes render (empty \"Global\" dropped) and lane height is measured-to-fit via `ResizeObserver` (clamped 84–132px); the date axis still scrolls horizontally. Also removed three silent layout fallbacks (`_peakMs`→latest, `xForMs(null)`→far-right edge, span-with-missing-dates) so a missing date omits a mark rather than faking a position. See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** (Summary → What's Next → Trace → Watch → Live Web Evidence) — de-tabbed + de-duplicated 2026-06-29 (it previously had its own competing tab widget + duplicated key actors + a `DisruptionPreview` that now live only in the center tabs) |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — forecast calibration scoreboard (stat cards, Brier score + verdict, calibration table, recently-resolved triggers); honest empty state until human-confirmed verdicts exist. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26). Renders **only** when the newest confirmed alert is <24h old — else nothing (no stale/fabricated banner). Links to `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`); 30min cache; powers `/track-record` |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "21fce1b2b3d4dc6f6f2c6cfe87758b4cf52016a5": {"t": 1785765827.1162157, "n": 131798, "h": "116d262f40f7f7d7", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-04 (refresh: **EMAIL DELIVERY LIVE** — the send path both products were missing is now sending real mail: **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`. Domain `globalperspective.net` **verified in Resend 2026-07-04** [Cloudflare auto-config: DKIM/SPF/MX/DMARC live], `EMAIL_SEND_DRY_RUN=false`, from `brief@globalperspective.net`, `TEST_RECIPIENT` cleared — a **real weekly send to the first subscriber confirmed** [`sent:1, errors:[]`]. Both rules ENABLED [`TriggerWeeklyEmailSend` Sun 14:00 UTC, `TriggerBreakingEmailSend` rate(15m)]; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]; 28/28 hermetic tests. **`SubscribeCard`** LIVE on Home + `/weekly-brief` [digest] + `/breaking` [breaking] — reuses the `digestOptIn`/`breakingOptIn` opt-ins, no backend change. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis with winners/losers (forecast — speculative by design)\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. Honest empty state until human-confirmed verdicts exist — see [Prediction calibration](#prediction-calibration-track-record) |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** none — the PairListPage/PairPage components and their `/weekly/pairs` + `/weekly/pair/:slug` routes were deleted in the \"Cut: orphans\" cleanup. The backend data + API actions remain; nothing renders them.\n**API actions:** `pair_analysis` (single pair by slug) + `pair_analyses_list` (all pairs, DDB Scan) — still served, currently unconsumed by the UI\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map. This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes `COUNTRY#{name}/DRIFT#{date}` + `THREAD#{threadId}/DRIFT#{date}` notes (never overwrites; `DRIFT_TTL_DAYS=60`).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts (LIVE 2026-07-04)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **ENABLED**. Both invoke with `{mode}`. **No LLM.** Built 2026-07-03, **went live 2026-07-04** (domain verified); plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Kill-switch:** `EMAIL_SEND_DRY_RUN=true` reverts to log-only (sends nothing); `TEST_RECIPIENT` (unset in prod) can override the audience to a single address for a send-to-self.\n\n**Current prod env (LIVE):** `EMAIL_SEND_DRY_RUN=false`, `EMAIL_FROM=brief@globalperspective.net`, `TEST_RECIPIENT` unset. Other env: `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live DONE 2026-07-04:** `globalperspective.net` verified in Resend (Cloudflare auto-config), flags flipped, breaking rule enabled, **one real weekly send to the first subscriber confirmed** (`sent:1, errors:[]`). Also verified: 28/28 hermetic tests (`test-sender.js`), live dry-run both modes. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, scenarios[{ label, probability(numeric midpoint), triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`.\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n**Three phases:**\n1. **Capture** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction` (scenarios → numeric probability midpoint + dated triggers). Never throws into the pipeline.\n2. **Resolve (hybrid)** — `newsPredictionResolver` (#20, daily 09:00 UTC) **proposes** verdicts grounded in Brave Search; a human **confirms** via `node predictions/review.js` (interactive / `--list` / `--accept-all`). No public auth surface — mirrors the economic-quality human-review pattern.\n3. **Score + surface** — `prediction_track_record` proxy action computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets (predicted vs actual fired-rate), and recent resolved list. `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) renders it with an **honest empty state** — shows nothing rather than a fabricated score until human-confirmed verdicts exist ([[feedback-no-misinformation-fallback]]).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane, span bar = how long the situation ran, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** (Summary → What's Next → Trace → Watch → Live Web Evidence) — de-tabbed + de-duplicated 2026-06-29 (it previously had its own competing tab widget + duplicated key actors + a `DisruptionPreview` that now live only in the center tabs) |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — forecast calibration scoreboard (stat cards, Brier score + verdict, calibration table, recently-resolved triggers); honest empty state until human-confirmed verdicts exist. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26). Renders **only** when the newest confirmed alert is <24h old — else nothing (no stale/fabricated banner). Links to `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`); 30min cache; powers `/track-record` |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "17533ff80873bb0f95cd47006d374a279eef08b4": {"t": 1785765828.974405, "n": 131688, "h": "c2310d5434d855e4", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-03 (refresh: **EMAIL SENDER + SUBSCRIBE BUTTON LIVE** — the email *send* path both products were missing: new **`newsEmailSender`** Lambda [#29, `event.mode` weekly|breaking] off the shared Resend seam + `GlobalPerspectiveUserPrefs`, **deployed to prod in DRY-RUN** [safe: logs recipients, sends nothing until a verified domain + flag flip]; `TriggerWeeklyEmailSend` Sunday cron ENABLED, `TriggerBreakingEmailSend` rate(15m) DISABLED-until-go-live; idempotency markers + 48h breaking-freshness gate; public one-click **unsubscribe** on `newsRecommend` [GetItem-by-uid, no new IAM]. Verified 28/28 hermetic tests + one real weekly send-to-self. **`SubscribeCard`** now LIVE on Home + `/weekly-brief` [reuses the `digestOptIn` opt-in — no backend change; browser-verified on prod]. Go-live remaining = operator verifies `globalperspective.net` in Resend. See `EMAIL_SENDER_PLAN.md`, [[project_email_sender]]. Prior: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis with winners/losers (forecast — speculative by design)\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. Honest empty state until human-confirmed verdicts exist — see [Prediction calibration](#prediction-calibration-track-record) |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** none — the PairListPage/PairPage components and their `/weekly/pairs` + `/weekly/pair/:slug` routes were deleted in the \"Cut: orphans\" cleanup. The backend data + API actions remain; nothing renders them.\n**API actions:** `pair_analysis` (single pair by slug) + `pair_analyses_list` (all pairs, DDB Scan) — still served, currently unconsumed by the UI\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map. This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes `COUNTRY#{name}/DRIFT#{date}` + `THREAD#{threadId}/DRIFT#{date}` notes (never overwrites; `DRIFT_TTL_DAYS=60`).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts (DEPLOYED in DRY-RUN 2026-07-03)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **DISABLED until go-live**. Both invoke with `{mode}`. **No LLM.** Built + deployed 2026-07-03; plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Safe-by-default:** `EMAIL_SEND_DRY_RUN` defaults ON (logs recipients + would-be email, sends nothing); `TEST_RECIPIENT` overrides the audience for send-to-self; real delivery additionally needs a verified `EMAIL_FROM` domain.\n\n**Key env vars:** `EMAIL_SEND_DRY_RUN`, `EMAIL_FROM`, `TEST_RECIPIENT`, `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live (operator):** verify `globalperspective.net` in Resend → flip `EMAIL_FROM`+`EMAIL_SEND_DRY_RUN=false`+clear `TEST_RECIPIENT` → enable the breaking rule. **Verified:** 28/28 hermetic tests (`test-sender.js`), live dry-run both modes, one real weekly sent to the owner. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, scenarios[{ label, probability(numeric midpoint), triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`.\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n**Three phases:**\n1. **Capture** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction` (scenarios → numeric probability midpoint + dated triggers). Never throws into the pipeline.\n2. **Resolve (hybrid)** — `newsPredictionResolver` (#20, daily 09:00 UTC) **proposes** verdicts grounded in Brave Search; a human **confirms** via `node predictions/review.js` (interactive / `--list` / `--accept-all`). No public auth surface — mirrors the economic-quality human-review pattern.\n3. **Score + surface** — `prediction_track_record` proxy action computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets (predicted vs actual fired-rate), and recent resolved list. `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) renders it with an **honest empty state** — shows nothing rather than a fabricated score until human-confirmed verdicts exist ([[feedback-no-misinformation-fallback]]).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane, span bar = how long the situation ran, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** (Summary → What's Next → Trace → Watch → Live Web Evidence) — de-tabbed + de-duplicated 2026-06-29 (it previously had its own competing tab widget + duplicated key actors + a `DisruptionPreview` that now live only in the center tabs) |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — forecast calibration scoreboard (stat cards, Brier score + verdict, calibration table, recently-resolved triggers); honest empty state until human-confirmed verdicts exist. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26). Renders **only** when the newest confirmed alert is <24h old — else nothing (no stale/fabricated banner). Links to `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`); 30min cache; powers `/track-record` |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "eb78dc786ffcdac1d82eb90a1d70100043d25bc8": {"t": 1785765830.8374548, "n": 130787, "h": "1b8978338f11384f", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-03 (refresh: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis with winners/losers (forecast — speculative by design)\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. Honest empty state until human-confirmed verdicts exist — see [Prediction calibration](#prediction-calibration-track-record) |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** none — the PairListPage/PairPage components and their `/weekly/pairs` + `/weekly/pair/:slug` routes were deleted in the \"Cut: orphans\" cleanup. The backend data + API actions remain; nothing renders them.\n**API actions:** `pair_analysis` (single pair by slug) + `pair_analyses_list` (all pairs, DDB Scan) — still served, currently unconsumed by the UI\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map. This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n5. **Public one-click unsubscribe** (added 2026-07-03) — `unsubscribe` action (no auth; the token IS the auth). Reached as a GET link from emails (`?action=unsubscribe&uid=<uid>&token=<unsubToken>&kind=<digest|breaking|all>`) or a RFC 8058 `List-Unsubscribe-Post` POST. **GetItem by `uid`** (the table key — deliberately not a Scan, so the existing role suffices), verifies `unsubToken` matches, flips `digestOptIn`/`breakingOptIn`, returns a small HTML confirmation page. Consumed by the `newsEmailSender` unsubscribe links (Lambda #29).\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs — the unsubscribe uses Get+Update, no Scan; read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'published'` (180-day TTL).\n\n**Auto-publish (gate removed 2026-07-03):** the schedule now writes `status:'published'` directly. The old manual `weekly/review.js` gate went stale (nothing published 2026-06-10..07-03 → `/weekly-brief` sat empty); the synthesis prompt is already strictly grounded, so the gate added stale-risk without quality upside for a solo operator. \"Set a brief to draft\" remains a manual kill-switch. (Serving-scan pagination was also fixed the same day — the published row had grown past the 1 MB scan page.)\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict). **Email delivery:** a separate `newsEmailSender` (Lambda #29) emails the published brief to subscribers — this function only generates/stores.\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes `COUNTRY#{name}/DRIFT#{date}` + `THREAD#{threadId}/DRIFT#{date}` notes (never overwrites; `DRIFT_TTL_DAYS=60`).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n### 29. `newsEmailSender` — one sender for weekly brief + breaking alerts (DEPLOYED in DRY-RUN 2026-07-03)\n**Path:** `amplify/backend/function/newsEmailSender/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyEmailSend` — `cron(0 14 ? * SUN *)` (Sundays 14:00 UTC, ~8h after the brief is generated) **ENABLED**; `TriggerBreakingEmailSend` — `rate(15 minutes)` **DISABLED until go-live**. Both invoke with `{mode}`. **No LLM.** Built + deployed 2026-07-03; plan → `EMAIL_SENDER_PLAN.md`.\n\nThe email **send** path both products were missing (content flowed, nothing sent — the Resend seam was orphaned). One Lambda, `event.mode` dispatch:\n1. `weekly` — paginated-scan the latest **published** `WEEKLY_BRIEF`, scan `GlobalPerspectiveUserPrefs` for `digestOptIn`, render (`renderWeeklyEmail.js`) + send one personalized email each, write idempotency marker `EMAILLOG#weekly#<weekOf>` in `SummarizeAndPredict` (re-invoke = no-op unless `{force:true}`).\n2. `breaking` — scan `GlobalPerspectiveBreakingAlerts` for `status:'confirmed'` **AND `emailedAt` unset AND `alertedAt` ≤48h** (freshness gate skips the ~50 historical `sent` alerts), reuse each alert's pre-rendered `draft{subject,html,text}`, send, stamp `emailedAt`.\n\nEmails carry `List-Unsubscribe`/`List-Unsubscribe-Post` headers; each links to `newsRecommend`'s public `unsubscribe` (uid+token). **Safe-by-default:** `EMAIL_SEND_DRY_RUN` defaults ON (logs recipients + would-be email, sends nothing); `TEST_RECIPIENT` overrides the audience for send-to-self; real delivery additionally needs a verified `EMAIL_FROM` domain.\n\n**Key env vars:** `EMAIL_SEND_DRY_RUN`, `EMAIL_FROM`, `TEST_RECIPIENT`, `RESEND_API_KEY`, `UNSUB_BASE_URL` (=newsRecommend Function URL), `SITE_URL`, `USER_PREFS_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`. **Role:** reuses `newsprojectLambdaRolefcb19312-dev` (`AmazonDynamoDBFullAccess` — chosen to avoid creating a new IAM role). **Go-live (operator):** verify `globalperspective.net` in Resend → flip `EMAIL_FROM`+`EMAIL_SEND_DRY_RUN=false`+clear `TEST_RECIPIENT` → enable the breaking rule. **Verified:** 28/28 hermetic tests (`test-sender.js`), live dry-run both modes, one real weekly sent to the owner. See [[project_email_sender]].\n\n**Subscribe UI:** `SubscribeCard` (Home under the masthead + `/weekly-brief` under the KPIs) surfaces the `digestOptIn` opt-in (via `usePreferences` → `set_prefs`) — signed-in users subscribe in one click; anonymous users sign in (Google), then the intent is set immediately.\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, scenarios[{ label, probability(numeric midpoint), triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`.\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n**Three phases:**\n1. **Capture** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction` (scenarios → numeric probability midpoint + dated triggers). Never throws into the pipeline.\n2. **Resolve (hybrid)** — `newsPredictionResolver` (#20, daily 09:00 UTC) **proposes** verdicts grounded in Brave Search; a human **confirms** via `node predictions/review.js` (interactive / `--list` / `--accept-all`). No public auth surface — mirrors the economic-quality human-review pattern.\n3. **Score + surface** — `prediction_track_record` proxy action computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets (predicted vs actual fired-rate), and recent resolved list. `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) renders it with an **honest empty state** — shows nothing rather than a fabricated score until human-confirmed verdicts exist ([[feedback-no-misinformation-fallback]]).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane, span bar = how long the situation ran, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** (Summary → What's Next → Trace → Watch → Live Web Evidence) — de-tabbed + de-duplicated 2026-06-29 (it previously had its own competing tab widget + duplicated key actors + a `DisruptionPreview` that now live only in the center tabs) |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — forecast calibration scoreboard (stat cards, Brier score + verdict, calibration table, recently-resolved triggers); honest empty state until human-confirmed verdicts exist. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26). Renders **only** when the newest confirmed alert is <24h old — else nothing (no stale/fabricated banner). Links to `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`); 30min cache; powers `/track-record` |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "a78ea0c69e1d7b54d9cd64e2f1c34914c9bf106b": {"t": 1785765834.369408, "n": 127230, "h": "d494291734bf4b56", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-03 (refresh: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis with winners/losers (forecast — speculative by design)\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. Honest empty state until human-confirmed verdicts exist — see [Prediction calibration](#prediction-calibration-track-record) |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Markets actions. **NOTE:** the read path hard-codes the literal `'GlobalPerspectiveMarkets'` — this env var is **not** read by `newsSensitiveData` code (it is by `newsMarketsData`). |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** none — the PairListPage/PairPage components and their `/weekly/pairs` + `/weekly/pair/:slug` routes were deleted in the \"Cut: orphans\" cleanup. The backend data + API actions remain; nothing renders them.\n**API actions:** `pair_analysis` (single pair by slug) + `pair_analyses_list` (all pairs, DDB Scan) — still served, currently unconsumed by the UI\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map. This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs, read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'draft'` (180-day TTL).\n\n**Human approval (gate kept):** the schedule generates a **draft**; a human publishes via `weekly/review.js` (`status → published`) before it's served. Generation scheduled; publishing manual.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict).\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n### 26. `newsDriftCorrector` — living-analysis drift corrector (LIVE 2026-07-02)\n**Path:** `amplify/backend/function/newsDriftCorrector/src/index.js`\n**Trigger:** EventBridge — `TriggerDriftCorrector` — daily ~07:20 UTC. Uses **DeepSeek V4** (reuses `newsCountryIntelligence-role`). Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n\nThe \"self-correcting analysis\" engine — detects when a country/thread read's **conclusion** moved (risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline; ~37% of daily updates are cosmetic reword = noise) and grounds the \"why\" in a **real cited archive event**, never inventing one.\n\n**What it does:**\n1. Reads the dated `HISTORY#`/`THREAD_HISTORY#` snapshots written by `newsCountryIntelligence`/`newsThreadAnalysis`.\n2. `findAllDrifts` finds **every** ungrounded conclusion-move in a 10-day window (so a missed cron day or pre-live history self-heals — last-week backtest = 11/11 moves grounded = 100%).\n3. Grounds each move with a numbered-event prompt → a single real trigger id or an honest `noSingleDriver` (never fabricates).\n4. Writes `COUNTRY#{name}/DRIFT#{date}` + `THREAD#{threadId}/DRIFT#{date}` notes (never overwrites; `DRIFT_TTL_DAYS=60`).\n\n**Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` \"What changed\" band + correction-history chain on CountryPage; a \"What changed\" block on ThreadPage.\n\n**Key code refs:** `findAllDrifts`, `COUNTRY#…/DRIFT#`, `THREAD#…/DRIFT#`, `DRIFT_TTL_DAYS=60` (`src/index.js:17,33,76,136,26`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 27. `newsAnalyze` — member \"our-compute\" Analysis Studio run path (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsAnalyze/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). Uses **DeepSeek V4**. The paid Polar-membership product; full design → `POLAR_BILLING_PLAN.md`.\n\nRuns the same cited-analysis product as the BYOK `/analyze` studio, but on **our** DeepSeek key instead of the user's — this is what membership/credits monetize.\n\n**What it does:**\n1. Verifies a Firebase JWT, then gates the run through **`consume()`**: member **monthly allowance** (`MEMBER_MONTHLY_ALLOWANCE` free runs via `analyzeMonth`/`analyzeCount`) → atomic conditional **credit** decrement → `402 out_of_credits` when neither remains. Refunds on LLM failure.\n2. Builds the cited context from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` and calls DeepSeek.\n\n**Credits layer (built 2026-06-30, prod deploy pending):** replaced the old hard `tier=member` gate + `ANALYZE_DAILY_CAP` daily counter. Pure helpers live in `src/lib.js` with a `node --test` suite (`npm test`).\n\n**Key code refs:** `consume()`, `MEMBER_MONTHLY_ALLOWANCE`, `out_of_credits` (`src/index.js:31,104,129`). **Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `USERS_DDB_TABLE`, `MEMBER_MONTHLY_ALLOWANCE`, `FIREBASE_PROJECT_ID`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold **DeepSeek** values). See [[project_credit_billing]], [[project_analysis_studio]].\n\n---\n\n### 28. `newsPolarBilling` — Polar checkout + webhook (LIVE 2026-06-22)\n**Path:** `amplify/backend/function/newsPolarBilling/src/index.js`\n**Trigger:** Lambda **Function URL** (CORS owned in code — keep the Function-URL CORS empty, see Common Mistakes #7). **No LLM.** Polar.sh is the Merchant of Record. Full design → `POLAR_BILLING_PLAN.md` §5.\n\nOwns the billing surface: `create_checkout`, the signed `order.paid` webhook, and `get_membership`.\n\n**What it does:**\n1. `create_checkout` — subscription (`$15/mo · $150/yr`) or, with `kind:'credits'`+`pack`, a one-time credit-pack Polar product (server-authoritative amounts via `POLAR_CREDIT_PACKS`).\n2. Webhook `order.paid` — routes **credit-pack** orders to an idempotent **`grantCredits`** (`processedOrders` String-Set dedupe so an order can't double-credit) vs **subscription** orders to `tier=member`.\n3. `get_membership` — returns `{ tier, creditBalance, currentPeriodEnd, … }`; powers the Account → Membership tab + the header credits pill (`useMembership`).\n\nWrites the live Polar/credits fields to `USERS_DDB_TABLE` (`billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd`, `creditBalance`, `processedOrders`).\n\n**Key code refs:** `grantCredits`, `processedOrders`, `POLAR_CREDIT_PACKS`, `get_membership` (`src/index.js:38,108,117`). **Key env vars:** `USERS_DDB_TABLE`, `POLAR_API_BASE`, `POLAR_ACCESS_TOKEN`, `POLAR_WEBHOOK_SECRET`, `POLAR_CREDIT_PACKS`. See [[project_billing_deprecated]], [[project_credit_billing]].\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, scenarios[{ label, probability(numeric midpoint), triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`.\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n**Three phases:**\n1. **Capture** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction` (scenarios → numeric probability midpoint + dated triggers). Never throws into the pipeline.\n2. **Resolve (hybrid)** — `newsPredictionResolver` (#20, daily 09:00 UTC) **proposes** verdicts grounded in Brave Search; a human **confirms** via `node predictions/review.js` (interactive / `--list` / `--accept-all`). No public auth surface — mirrors the economic-quality human-review pattern.\n3. **Score + surface** — `prediction_track_record` proxy action computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets (predicted vs actual fired-rate), and recent resolved list. `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) renders it with an **honest empty state** — shows nothing rather than a fabricated score until human-confirmed verdicts exist ([[feedback-no-misinformation-fallback]]).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsDriftCorrector, newsAnalyze | Topic clustering + AI content generation |\n| OpenRouter (`openrouter.ai`) | newsPostDevTo (#7) | Long-form Dev.to article generation via `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`) — NOT the direct DeepSeek API |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 27 `<Route>` elements incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane, span bar = how long the situation ran, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `/__boom` | `Boom` (inline in App.jsx) | ⚠ **Deliberate test route — keep, it's load-bearing.** `Boom()` throws on render to exercise the `ErrorBoundary` + `newsClientErrors` capture path. `scripts/smoke-test.mjs` drives it (`checkErrorBoundary`, navigates to `/__boom`) to verify a render crash is CONTAINED (persistent nav/chrome, no white screen) and reported — one of the `BUG_PLAYBOOK.md` bug-class checks. Ships in the prod bundle; not linked in nav; harmless unless navigated to. Do **not** delete without also updating the smoke test. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~64 component files (in `src/components/` + 17 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** (Summary → What's Next → Trace → Watch → Live Web Evidence) — de-tabbed + de-duplicated 2026-06-29 (it previously had its own competing tab widget + duplicated key actors + a `DisruptionPreview` that now live only in the center tabs) |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — forecast calibration scoreboard (stat cards, Brier score + verdict, calibration table, recently-resolved triggers); honest empty state until human-confirmed verdicts exist. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26). Renders **only** when the newest confirmed alert is <24h old — else nothing (no stale/fabricated banner). Links to `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n32 hook files (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useNarrativeThread(threadId)` | Fetch all entries for a single thread across days (`narrative_thread`) — powers `ThreadPage` |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`); 30min cache; powers `/track-record` |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useMembership()` | Fetch the signed-in user's membership + `creditBalance` via `newsPolarBilling` `get_membership` (#28); powers the header credits pill, Account → Membership tab, and the `/analyze` server-run gate. Exposes `available` (false until billing is wired) |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Inferring the AI provider from env var names** — see #3 (kept here as a reminder). The old \"ignore these outdated docs\" list (`HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md`) was removed — none of those files exist in the repo anymore\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "e091f75b7be43c0d6a1473a982e2cf7a9881b43a": {"t": 1785765837.1200778, "n": 121071, "h": "4b0283a54c85d013", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-03 (refresh: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis with winners/losers (forecast — speculative by design)\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. Honest empty state until human-confirmed verdicts exist — see [Prediction calibration](#prediction-calibration-track-record) |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Required (markets actions; default `GlobalPerspectiveMarkets`) |\n| `USERS_DDB_TABLE` | Required (for tier lookup) |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `PADDLE_API_KEY` | Portal sessions only — unset in deployed `newsSensitiveData-dev` while billing is dormant |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** none — the PairListPage/PairPage components and their `/weekly/pairs` + `/weekly/pair/:slug` routes were deleted in the \"Cut: orphans\" cleanup. The backend data + API actions remain; nothing renders them.\n**API actions:** `pair_analysis` (single pair by slug) + `pair_analyses_list` (all pairs, DDB Scan) — still served, currently unconsumed by the UI\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map. This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs, read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'draft'` (180-day TTL).\n\n**Human approval (gate kept):** the schedule generates a **draft**; a human publishes via `weekly/review.js` (`status → published`) before it's served. Generation scheduled; publishing manual.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict).\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, scenarios[{ label, probability(numeric midpoint), triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`.\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n**Three phases:**\n1. **Capture** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction` (scenarios → numeric probability midpoint + dated triggers). Never throws into the pipeline.\n2. **Resolve (hybrid)** — `newsPredictionResolver` (#20, daily 09:00 UTC) **proposes** verdicts grounded in Brave Search; a human **confirms** via `node predictions/review.js` (interactive / `--list` / `--accept-all`). No public auth surface — mirrors the economic-quality human-review pattern.\n3. **Score + surface** — `prediction_track_record` proxy action computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets (predicted vs actual fired-rate), and recent resolved list. `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) renders it with an **honest empty state** — shows nothing rather than a fabricated score until human-confirmed verdicts exist ([[feedback-no-misinformation-fallback]]).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsPostDevTo | Topic clustering + AI content generation |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 21 routes incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane, span bar = how long the situation ran, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~66 component files (in `src/components/` + 14 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** (Summary → What's Next → Trace → Watch → Live Web Evidence) — de-tabbed + de-duplicated 2026-06-29 (it previously had its own competing tab widget + duplicated key actors + a `DisruptionPreview` that now live only in the center tabs) |\n| `CountryListPage.jsx` | Index of all countries with intelligence. Briefings grid is **risk-tier banded** (2026-07-03): High = full `CountryCard`s · Elevated/Moderate/Low = condensed `CountryRow`s (density decay; risk is the axis since countries are persistent). Banding shows on the default **risk sort**; other sorts (Coverage/Disruption/A→Z) render a flat grid. Requests briefings for the top **24** countries (was 10) to cover the backend's `MAX_COUNTRIES=20` — a single batched call; countries without backend intel fall to the correctly-labeled \"Others\" list. Overview map, left rail (search/sort/region), right rail (Highest Risk + Most Covered leaderboards) |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — forecast calibration scoreboard (stat cards, Brier score + verdict, calibration table, recently-resolved triggers); honest empty state until human-confirmed verdicts exist. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26). Renders **only** when the newest confirmed alert is <24h old — else nothing (no stale/fabricated banner). Links to `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n25 total hooks (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`); 30min cache; powers `/track-record` |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Reading old planning docs** — `HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md` are all outdated and should be ignored\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}, "8f68f1c0a538077f93510f03b23b17f221318e68": {"t": 1785765840.3513267, "n": 120476, "h": "0b72f7442f7c36e5", "c": "# Global Perspectives — Architecture Overview\n\n**Last verified:** 2026-07-03 (refresh: **RISK TIERS MIGRATION COMPLETE** [`RISK_TIERS_PLAN.md` P1+P2+P3, prod-verified] — new `src/utils/riskTiers.js` is the single source of truth for score→tier on the **canonical 25/50/75 bands**, collapsing **four** divergent band definitions + fixing the moderate tier [was always orange \"elevated\"; now amber]. Current-state risk displays across ThreadPage/CountryPage/WorldMapV2 now lead with the **tier word** + demote the raw score to fine print [rule: *state = tier · change = audit numbers*]; `/weekly` gains a **LEAD + DEVELOPING front-page hierarchy** above the category river [tier-qualified, drift-delta line, hides in work-mode, honest-empty]. See the Design-Tokens + `WeeklyPage` notes below. Prior: **LIVING-ANALYSIS LOOP LIVE + browser-verified** — self-correcting country/thread analysis. New `newsDriftCorrector` Lambda [#26, DeepSeek, daily `TriggerDriftCorrector` cron 07:20 UTC] detects when a read's *conclusion* moved [risk level / |Δscore|≥8 / trajectory — gated on the conclusion, NOT the headline] and grounds the \"why\" in a **real cited archive event** [numbered-event prompt → single trigger or honest \"no single driver\"; never invents], writing `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes [never overwrites, 60d TTL]. It **backfills every ungrounded move** in its 10-day window [`findAllDrifts`], so pre-live history / a missed cron day self-heals to full coverage — last-week backtest = **11/11 moves grounded [100%]**. `newsCountryIntelligence` feeds recent notes forward as a low-authority \"RECENT CORRECTIONS\" block [1b.5]; `newsThreadAnalysis` now writes `THREAD_HISTORY#<date>` snapshots. Surfaced as the **\"What changed\" band + expandable correction-history chain** on CountryPage [`atoms/CountryWhatChanged`, served via `country_history.driftNotes[]`] and a \"What changed\" block on ThreadPage [`thread_analysis.driftNote`]. Playwright-verified live [Ukraine/Japan/Russia]. Prior: **Weekly Markets consolidated into `/economy`** — the standalone `/weekly-markets` page became a \"This week\" mode behind a Today/This-week toggle [`?view=week`], rendered by the new `WeeklyMarketsView`; `/weekly-markets` is now a redirect permalink; the \"Markets\" nav item was removed. Merged to main `41f2b5a`, not yet built/deployed. Prior: **narrative-page layout cleanup** LIVE — ThreadPage collapsed to a single tab system [right \"Arc Intelligence\" rail de-tabbed → stacked synthesis column, actors/economy de-duplicated to the center tabs]; CountryPage's `SystemsGraph` causal web promoted out of the overloaded right rail into a full-width center **Causal Web** tab. Prior: **Weekly Markets Report** LIVE — price-first `/weekly-markets` \"what moved & why\" wrap via the new `newsWeeklyMarkets` Lambda [#25] + `weekly_markets` serve action, first report published & browser-tested; deterministic moves + 3 honesty-layered grounding tiers; a markets-history gap [06-06..06-21] was backfilled (delete-null-rows → seed_history), also fixing `/economy` sparklines. Prior: breaking-alert **web surface** LIVE — `/breaking` feed + `/breaking/:id` detail + Home/Map `BreakingStrip`, bell rewired off the thread page; `newsRecommend` `get_alert` + enriched `list_alerts` deployed. Prior: `src/tokens.js` single source for risk/category colors [P2a]; P0/P1 + systems-graph product-improvement work deployed live; Stooq→Yahoo markets migration, Polar membership LIVE + `newsAnalyze`/`newsPolarBilling`, `/membership` route, `newsPostDevTo` TZ correction)\n\n> **For the code-grounded, evidence-based wiring of frontend↔backend↔DDB, see [`SYSTEM_WIRING.md`](./SYSTEM_WIRING.md). For evidence-based optimization findings (incl. measured speedups), see [`OPTIMIZATION_REPORT.md`](./OPTIMIZATION_REPORT.md).**\n\nGlobal Perspectives is an AI-powered global news aggregation platform. It fetches real news from RSS feeds and Brave Search, clusters articles into topics using DeepSeek V4, generates AI insights (summaries, predictions, root-cause analysis), and displays everything on an interactive world map and weekly narrative timeline.\n\n**AI Provider (as of 2026-05-16):** All LLM calls migrated off xAI Grok (credits exhausted 2026-05-03). See `AI_PROVIDER_MIGRATION_PLAN.md` for full migration history. Env var names (`XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL`) are **legacy** — they hold DeepSeek/Gemini values in production. Always confirm the real provider with `aws lambda get-function-configuration`, never infer from the variable name.\n- `newsInvokeGemini-dev`, `NewsProjectInvokeAgentLambda-dev`, `newsCountryIntelligence`, `newsDriftCorrector`, `newsPostDevTo`, `newsSystemsAnalysis`, `newsEconomicImpact` → **DeepSeek V4 Flash** (`deepseek-chat`, $0.14/M in · $0.28/M out)\n- `newsThreadAnalysis`, `newsEconomicQuality` → **Gemini 2.5 Flash** (free tier, 13s pacing between calls, thinking disabled)\n- `newsPairIntelligence` → **DeepSeek V4** (deployed env vars verified `deepseek-chat` / `api.deepseek.com` — its source default still reads Grok, and the migration plan's Phase 2 box was never checked, but the live function is on DeepSeek). Manual-invoke only.\n- `newsAnalyze` → **DeepSeek V4** (the member-side Analysis Studio \"our-compute\" run path — same cited-analysis product as BYOK `/analyze` but on our key; member-gated, `ANALYZE_DAILY_CAP=50`; deployed 2026-06-22, the paid Polar membership product)\n- `newsMarketsData`, `newsCountryFactsUpdater`, `newsSavedItems`, `newsPostLinkedIn`, `linkedInAutoPost`, `newsPolarBilling` → **no LLM** (data feeds, cached AI from DDB, or — for `newsPolarBilling` — Polar checkout + webhook, deployed 2026-06-22)\n- `newsClientErrors`, `newsFreshnessMonitor`, `newsErrorDigest` → **no LLM** (observability — passive error capture + 24/7 monitoring; see Lambdas #17–19 and \"Observability & Monitoring\")\n- `newsBreakingAlert` → **no LLM today** (deterministic significance detector; the Phase-3 verify step will add **Gemini** as a judge). Built 2026-06-10, **not deployed** — see Lambda #21.\n\n- **Production URL:** https://globalperspective.net (custom domain)\n- **GitHub Pages URL:** https://benben05059997.github.io/globalPerspective-v1/\n- **Frontend hosting:** GitHub Pages (served from `docs/`)\n- **Backend:** AWS Lambda + API Gateway + DynamoDB (managed via Amplify)\n\n---\n\n## System Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│ SCHEDULED PIPELINE (EventBridge) │\n└─────────────────────────────────────────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsInvokeGemini │\n │ RSS Feeds (26) + Brave Search │\n │ → DeepSeek V4 clusters topics │\n │ → DynamoDB Topics[id=staging] │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ NewsProjectInvokeAgentLambda │\n │ Reads staging topics │\n │ → DeepSeek V4 generates: │\n │ SUMMARY / PREDICTION / │\n │ TRACE_CAUSE per topic │\n │ → Assigns threadId │\n │ → Writes Summary DDB │\n │ → Swaps staging → latest │\n │ → Writes today-archive │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsThreadAnalysis │\n │ Daily batch (6:30 UTC) │\n │ Top 10 threads (2+ entries) │\n │ → Gemini 2.5 Flash generates: │\n │ threadTitle, storyArc, │\n │ trajectory, rootCauseChain, │\n │ watchQuestions │\n │ → Writes THREAD# to SummaryDB │\n └───────────────┬───────────────┘\n ┌───────────────▼───────────────┐\n │ newsCountryIntelligence │\n │ Daily batch (07:00 UTC) │\n │ Top 20 countries by articles │\n │ Uses thread analyses + Brave │\n │ → DeepSeek V4 generates: │\n │ headline, situationSummary, │\n │ trajectory, riskSignals, │\n │ riskLevel │\n │ → Writes COUNTRY# to SummaryDB│\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSystemsAnalysis │\n │ Daily batch (07:15 UTC) │\n │ Top N countries (test: 2) │\n │ Maps causal links between │\n │ story threads + confidence │\n │ → DeepSeek V4 generates: │\n │ nodes[], edges[], lagDays │\n │ → Writes SYSTEMS# to SummaryDB\n └───────────────┬───────────────┘\n │\n ┌────────────────────▼────────────────────┐\n │ newsPostLinkedIn │\n │ Reads latest + summaries │\n │ → Posts to LinkedIn / Bluesky / │\n │ Farcaster / Mastodon / Telegram │\n │ → Deduplicates via SOCIAL_POSTS_TABLE │\n └─────────────────────────────────────────┘\n │\n ┌───────────────▼───────────────┐\n │ newsSensitiveData │\n │ API Gateway REST endpoint │\n │ ~27 actions — all content │\n │ public during early access │\n └───────────────┬───────────────┘\n │\n ┌───────────────▼───────────────┐\n │ React Frontend (GitHub Pages)│\n │ restProxy.js → API Gateway │\n │ Firebase Auth (magic link) │\n │ LocalStorage cache (1hr TTL) │\n │ Background poll every 10min │\n └────────────────────────────────┘\n```\n\n---\n\n## Auth System\n\n**Firebase Authentication** — two methods: passwordless email link (magic link) and Google Sign-In.\n\n**Magic link flow:**\n1. User enters email on `/signin`\n2. Firebase sends a magic link email\n3. User clicks link → lands on `/auth/callback`\n4. `AuthCallback` calls `completeSignIn()` → Firebase signs user in, sets welcome flag in sessionStorage\n\n**Google Sign-In flow:**\n1. User clicks Google button on `/signin`\n2. `signInWithGoogle()` in `AuthContext.jsx` calls `signInWithPopup` + `GoogleAuthProvider`\n3. Firebase signs user in immediately\n\n**In the frontend:**\n- `AuthContext.jsx` manages Firebase auth state (`onAuthStateChanged`), exposes `signInWithGoogle()`\n- `AuthBridge` in `App.jsx` calls `setAuthProvider(getIdToken)` on mount, wiring the token getter into `restProxy`\n- `proxyActionWithAuth()` in `restProxy.js` sends `Authorization: Bearer <token>` header on gated requests\n\n**First sign-in:** `newsSensitiveData` auto-creates a user record in `USERS_TABLE` (`uid`, `email`, `trialStartedAt`) on first JWT-gated request.\n\n**Firebase config** is read from `window.FIREBASE_CONFIG` (set in `docs/config.js` at runtime — never bundled into the build). Falls back to `VITE_FIREBASE_*` env vars for local dev.\n\n**Tier enforcement** is handled in `newsSensitiveData` (Lambda) — see Tier System below.\n\n---\n\n## Membership / Billing (Polar) — LIVE 2026-06-16..22; reading stays free\n\n> **CURRENT MODEL (deployed; source of truth = [`POLAR_BILLING_PLAN.md`](./POLAR_BILLING_PLAN.md)):** All **content is 100% free and public — there is NO read-tier gating** (`newsSensitiveData` enforces nothing; that has not changed). **Membership buys analysis *compute*, not access:** a member runs the Analysis Studio (`/analyze`) on **our** DeepSeek instead of bringing their own key — member-gated via the `newsAnalyze` Lambda with `ANALYZE_DAILY_CAP=50`. Live products: **$15/mo · $150/yr**. **Polar.sh is the Merchant of Record** (handles checkout + card data + tax; real payouts pending operator KYC). Backend deployed + machine-tested 2026-06-22 — `newsPolarBilling` (checkout + webhook, Function URL, signing secret set) and the member-side `newsAnalyze` (our-compute run path). Frontend: `/membership` page + footer link + `?returnTo=` auth funnel (see [[project-billing-deprecated]], [[project-analysis-studio]]). The four legal/marketing pages were reconciled 2026-06-22 to \"free to read; membership buys compute, not access.\" Operator TODO still open: 100%-off comp run, KYC, **rotate the leaked token**.\n>\n> **CREDITS LAYER — code built + SANDBOX-VERIFIED 2026-06-30; prod-code deploy still pending.** Extends the pure-subscription model into *subscription allowance + purchasable credit top-ups* (decisions locked: members get `MEMBER_MONTHLY_ALLOWANCE` free runs/month then spend credits; **any signed-in user** can buy credits + run pay-as-you-go; **1 credit = 1 run**). `newsAnalyze` dropped the hard `tier=member` gate + the old `ANALYZE_DAILY_CAP` daily counter → new `consume()` (monthly allowance → atomic conditional credit decrement → `402 out_of_credits`, refunds on LLM failure). `newsPolarBilling` `create_checkout` gained a `kind:'credits'`+`pack` path → one-time Polar product; webhook `order.paid` routes credit-pack orders to an idempotent `grantCredits` (`processedOrders` set-dedupe) vs subscription orders to `tier=member`; `get_membership` returns `creditBalance`. Server-authoritative amounts via env `POLAR_CREDIT_PACKS`. Pure helpers live in each function's `src/lib.js` with `node --test` suites (`npm test`). Frontend: `MembershipPage` buy-credits section, **Account → Membership tab + a header credits pill** (both via `useMembership` → `creditBalance`), `AnalysisStudio` server path open to members **and** credit-holders.\n>\n> **SANDBOX TEST (2026-06-30):** isolated `newsPolarBilling-sandbox` + `newsAnalyze-sandbox` Lambdas deployed via `amplify/backend/function/_sandbox/deploy-sandbox.sh` (reuses prod roles, `POLAR_API_BASE=https://sandbox-api.polar.sh`, `MEMBER_MONTHLY_ALLOWANCE=2`, 5 sandbox products created). Signed-webhook test proved grant → idempotent replay → accumulation → sub-vs-pack routing against real DynamoDB. Frontend pointed at sandbox via the gitignored `public/config.js`. **Found + fixed a real bug (also present in PROD):** the Lambda code AND the Function-URL CORS config each emit `Access-Control-Allow-Origin` → duplicate header → browsers fail with \"Failed to fetch\" at checkout. Fix = clear the Function-URL CORS so the code is the sole CORS source (config-only, no redeploy). Applied to both sandbox + prod functions; the deploy script no longer sets `--cors`.\n>\n> **LIVING-ANALYSIS LAYER — LIVE + browser-verified 2026-07-02.** The differentiator wedge (`PITCH.md` #1): analysis that *self-corrects* as news arrives and shows **what changed & why**, grounded, never a silent overwrite. Architecture = **corrector → drift-note → analyzer** (reuses the agent pattern, no version store). Pieces: (1) `newsCountryIntelligence`/`newsThreadAnalysis` write dated `HISTORY#`/`THREAD_HISTORY#` snapshots of each read. (2) **`newsDriftCorrector`** (daily `TriggerDriftCorrector` cron ~07:20 UTC; DeepSeek; reuses `newsCountryIntelligence-role`) reads the snapshots, finds **every** conclusion-move in a 10-day window (`findAllDrifts`, gated on risk level / |Δscore|≥8 / trajectory — *not* the headline; ~37% of daily updates are cosmetic reword = noise), grounds each ungrounded move in a **real archive event** (numbered-event prompt → one trigger id or honest `noSingleDriver`; never invents), and writes `COUNTRY#/DRIFT#<date>` + `THREAD#/DRIFT#<date>` notes (never overwrites, 60d TTL). Backfilling = self-heals: a missed cron day or pre-live history is grounded on the next run (last-week backtest = 11/11 moves = 100% coverage). (3) **Feed-forward (1b.5):** `newsCountryIntelligence` reads the last 2 notes as a low-authority \"RECENT CORRECTIONS\" block so the next read builds on its own corrections. **Serve:** `newsSensitiveData` `country_history` returns `driftNotes[]`; `thread_analysis` attaches the latest `driftNote`. **UI:** `atoms/CountryWhatChanged` renders the \"What changed\" band (deterministic delta + grounded \"↳ Because: <cited event>\") **plus an expandable correction-history chain** (every dated move + from→to + event); ThreadPage shows a \"What changed\" block in the Arc Intelligence rail. Key principle (prior-art `LIVING_ANALYSIS_PLAN.md`): the corrector is a **grounded event-detector, not self-reflection** (DeepMind: LLMs can't reliably self-correct without external feedback). Honest gaps: threads populate over ~2 daily cycles; Phase 2 (LLM gate) deferred as marginal; Phase 4 (predictions-as-drift-source) blocked on the empty track record. Plan: `LIVING_ANALYSIS_PLAN.md`; memory [[project_living_analysis]].\n>\n> **Operator go-live for prod credits:** create one-time Polar credit-pack products (live) → set `POLAR_CREDIT_PACKS` + `MEMBER_MONTHLY_ALLOWANCE` on the prod Lambdas → add `window.POLAR_CREDIT_PACKS` to `docs/config.js` → **deploy the new `newsAnalyze` + `newsPolarBilling` code to prod** (prod still runs the pre-credits code). Full detail in `POLAR_BILLING_PLAN.md` §5.\n\n> **Paddle is permanently dead** (torn down 2026-05-26..06-01 — `newsStripeWebhook` Lambda + Function URL + IAM role deleted). Do NOT revive it; Polar is a different provider with a different schema (`polarCustomerId`/`polarSubscriptionId`). The legacy `free`/`member`/`enterprise` tier table below is **historical** — the live model is the free-content + compute-membership split described above, not these access tiers.\n\n| Tier (legacy) | Intended access |\n|------|--------|\n| `free` | Public topics only (no auth required) |\n| `member` | 7-day archive + thread/country intelligence |\n| `enterprise` | 90-day archive + all features |\n\nIn practice **every visitor gets full access** — tier is display-only metadata and enforces nothing.\n\n**Storage (legacy):** DynamoDB `USERS_TABLE`, keyed by Firebase UID (`uid`). Records may still carry `tier` / `paddleCustomerId` / `paddleSubscriptionId` fields from before deprecation (these fields are now never read).\n\n**Lifecycle (removed 2026-06-01):** the `newsStripeWebhook` Lambda (handled Paddle) was deleted along with its Function URL and IAM role. There is no billing/subscription sync path anymore.\n\n**Enforcement:** None. All auth gates were removed from `newsSensitiveData` on 2026-04-11. The `resolveUserTier` helper and the `user_profile` / `portal_session` proxy actions were removed 2026-06-01. Archive, thread/country intelligence, narrative thread, economic, and daily-brief actions are all open with no JWT.\n\n**Paddle Customer Portal (removed):** the `/account` billing UI and its `portal_session` call were deleted from the frontend on 2026-05-26; the backend `portal_session` action was removed 2026-06-01.\n\n---\n\n## Lambda Functions\n\n### 1. `newsInvokeGemini`\n**Path:** `amplify/backend/function/newsInvokeGemini/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeGoogleGemini` — `cron(0 */4 * * ? *)` (every 4 hours)\n\nDespite the name, now uses **DeepSeek V4 Flash** (`deepseek-chat`) — no Gemini, no xAI.\n\n**What it does:**\n1. Fetches articles from:\n - **RSS feeds** (26): Global outlets (BBC, Al Jazeera, France24, SCMP, etc.) + climate (Inside Climate News, Grist) + tech (Ars Technica, MIT Tech Review)\n - **Brave Search** (10 dynamic queries): News + web search for regional outlets (Reuters, AP, Straits Times, Times of India, Korea Herald, Kyiv Independent, Latin America) + climate/energy, science, business/society\n2. Filters articles older than 48 hours; deduplicates by URL\n3. Checks soft-deduplication table (24hr window) to avoid re-covering the same story\n4. Sends to DeepSeek V4 to cluster into topics with category diversity quota\n5. Validates all URLs returned by the LLM against actually-fetched articles (hallucination filter)\n6. Assigns `continues_topic` field by scanning 7 days of past archive\n7. Writes to DynamoDB Topics table as `id=staging`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — actually holds DeepSeek key in production. |\n| `GROK_API_URL` | **Legacy name** — actually points at `https://api.deepseek.com` in production. |\n| `GROK_MODEL` | **Legacy name** — actually `deepseek-chat` in production. Source default `grok-4-1-fast-non-reasoning` is dead fallback. |\n| `BRAVE_SEARCH_API_KEY` | Optional; falls back to RSS-only. |\n| `BRAVE_CONCURRENCY` | Default `3`. Worker pool for parallel Brave queries (replaced 2s-sleep sequential loop 2026-05-18). |\n| `TOPICS_DDB_TABLE` | Required. |\n| `TOPICS_CACHE_ITEM_ID` | Default: `staging`. |\n| `TOPICS_LIMIT` | AWS env var currently set to `13`. Code `DEFAULT_LIMIT=15` only applies if env var is unset. |\n\n---\n\n### 2. `NewsProjectInvokeAgentLambda`\n**Path:** `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeNewsAgent` — `cron(5 */4 * * ? *)` (every 4 hours at :05, runs after newsInvokeGemini)\n\n**What it does:**\n1. Reads topics from `id=staging` in Topics table\n2. For each topic, calls DeepSeek V4 (via native `fetch()`) to generate:\n - **SUMMARY** — 3-4 bullet-point key takeaways. **Grounded in the article snippets (fixed 2026-06-14):** previously the prompt summarized from the *title only*, so it confabulated (e.g. asserting a referendum was \"rejected by a wide margin\" before the vote) and stapled the run-date in. Now it is fed `topic.sources[].snippet` with strict rules — only what the snippets report, preserve hedges, no unreported results/figures, no invented dates. Site-wide impact (everything downstream consumes SUMMARY). Audited daily by `newsSourceAudit` (#24).\n - **PREDICTION** — chain-reaction analysis with winners/losers (forecast — speculative by design)\n - **TRACE_CAUSE** — historical context and root cause (grounded in snippets; 2026-06-14 added: proximate event + specific names/figures/dates must come from snippets, no hedge-stripping)\n3. Assigns `threadId` to each topic:\n - If topic has `continues_topic` → inherit parent's `threadId`\n - Else → Jaccard similarity (keywords + regions + category, threshold 0.4) against 7 days of archive\n - Else → generate new `thread-{slug}-{hash}`\n - **threadId is computed from the RAW staging topics (which retain `continues_topic`/`category`/`search_keywords`) and stamped onto each topic BEFORE the swap, so the served `latest` topics carry `threadId`** (fixed 2026-06-10 — previously only archive entries got it, so `latest` had none and narrative links silently failed). The same map is reused for the archive so `latest` and the archive stay in sync.\n4. Writes each to Summary/Prediction DDB table with TTL\n5. Swaps Topics `staging` → `latest` (now threadId-stamped)\n6. Writes today's topics as `today-archive` entry (entries carry `threadId` + `search_keywords` for next-day threading)\n7. Prunes old cache entries\n\n**Payload options:**\n```json\n{ \"action\": \"both\" } // \"summary\", \"prediction\", \"trace_cause\", or \"both\"\n{ \"topicId\": \"specific-id\" } // Process one topic only\n{ \"readOnly\": true } // Read cache without generating\n```\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `XAI_API_KEY` | Required. **Legacy name** — holds DeepSeek key. |\n| `GROK_API_URL` | **Legacy name** — points at `https://api.deepseek.com/chat/completions` in production. |\n| `GROK_MODEL` | **Legacy name** — `deepseek-chat` in production. |\n| `TOPICS_DDB_TABLE` | Required. |\n| `SUMMARIZE_PREDICT_TABLE` | Required. |\n| `MAX_TOKENS` | Default: `600`. |\n| `TEMPERATURE` | Default: `0.2`. |\n| `TOP_P` | Default: `0.9`. |\n| `LLM_CONCURRENCY` | Default `4`. Worker pool for concurrent per-topic LLM calls (replaced sequential loop 2026-05-18, 3.0× speedup). |\n| **Memory / Timeout** | **512MB / 600s** (bumped from 128MB/445s on 2026-05-18 to eliminate 14% timeout failure rate). |\n\n---\n\n### 3. `newsThreadAnalysis`\n**Path:** `amplify/backend/function/newsThreadAnalysis/src/index.js`\n**Trigger:** EventBridge — `cron(30 6 * * ? *)` (6:30 UTC daily)\n\n**What it does:**\n1. Reads 30 days of archive entries from Topics table\n2. Groups entries by `threadId`; selects top 10 threads with 2+ entries\n3. For each thread, searches Brave News + Web for external grounding\n4. Calls **Gemini 2.5 Flash** (13s pacing between calls for the free-tier RPM ceiling, thinking disabled, `MAX_TOKENS=6000`) to generate:\n - `threadTitle` — sharp 6-10 word journalistic title\n - `entryShortTitles` — micro-headline per entry (`{topicId, shortTitle}`)\n - `storyArc` — 2-3 paragraphs on how the story evolved\n - `trajectory` — 2 paragraphs on where it's heading (named scenarios + timeframes)\n - `rootCauseChain` — 3-layer root cause (immediate trigger → medium-term condition → structural factor)\n - `watchQuestions` — 3 specific, actionable watch questions\n5. Skips threads where `entryCount` hasn't changed since last run\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `THREAD#{threadId}` / `THREAD_ANALYSIS` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `6000`), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n> ⚠️ Gemini free tier caps at ~20 requests/day. Fine for one scheduled run (7-10 calls), but manual test invokes the same day can exhaust quota.\n\n---\n\n### 4. `newsCountryIntelligence`\n**Path:** `amplify/backend/function/newsCountryIntelligence/src/index.js`\n**Trigger:** EventBridge Scheduler — `countryIntelliegence` — `cron(0 7 * * ? *)` (daily 07:00 UTC; reduced from 3×/day on 2026-05-16 to cut cost after the DeepSeek migration)\n\n**What it does:**\n1. Reads 30 days of archive entries; groups by country (`regions` field)\n2. Loads existing thread analyses for cross-thread enrichment\n3. Selects top 20 countries with 2+ articles\n4. For each country, searches Brave News for fresh context\n5. Calls **DeepSeek V4** to generate:\n - `headline` — 8-12 word sharp situation headline\n - `situationSummary` — 2-3 paragraph intelligence briefing\n - `crossThreadInsight` — connections between story arcs\n - `trajectory` — 2 paragraphs with named scenarios + triggers\n - `riskSignals` — 3-4 specific, concrete watch events\n - `riskLevel` — `low` | `moderate` | `elevated` | `high`\n6. Skips countries where `totalArticles` count hasn't changed\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `COUNTRY#{countryName}` / `COUNTRY_INTELLIGENCE` (90-day TTL)\n\n**Key env vars:** `XAI_API_KEY`, `GROK_MODEL`, `GROK_API_URL` (legacy names; hold DeepSeek values in production — see Lambda #1 notes), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`, `LLM_CONCURRENCY` (default `4`; added 2026-05-18 — 5.8× speedup).\n\n---\n\n### 5. `newsSensitiveData`\n**Path:** `amplify/backend/function/newsSensitiveData/src/index.js`\n**Trigger:** API Gateway HTTP POST from frontend\n\nRead-only REST proxy. All supported actions:\n\n| Action | Auth | Payload | Description |\n|--------|------|---------|-------------|\n| `topics` | None | — | Returns `latest` topics |\n| `summary` | None | `{ topicId }` | Returns cached SUMMARY |\n| `prediction` | None | `{ topicId }` | Returns cached PREDICTION |\n| `trace_cause` | None | `{ topicId }` | Returns cached TRACE_CAUSE |\n| `research_briefing` | None | `{ topicId }` | Returns cached RESEARCH_BRIEFING |\n| `geocode` | None | `{ address }` | Mapbox lat/lng lookup |\n| `today` | None | — | Today's archive entries |\n| `rss` | None (GET) | — | RSS 2.0 XML feed of latest topics |\n| `country_preview` | None | `{ countryName }` | Public SEO preview: headline, bluf, keyDevelopments, riskLevel, trajectory |\n| `thread_preview` | None | `{ threadId }` | Public SEO preview: threadTitle, entryShortTitles |\n| `archive_range` | None (early access) | `{ days }` | N days of archive (member=7, enterprise=90) |\n| `narrative_thread` | None (early access) | `{ threadId }` | All entries for a thread across days |\n| `thread_analysis` | None (early access) | `{ threadIds }` | Thread-level AI analyses |\n| `country_intelligence` | None (early access) | `{ countryNames }` | Country-level AI intelligence |\n| `country_history` | None (early access) | `{ countryName }` | Historical archive entries for a country |\n| `systems_analysis` | None (early access) | `{ countryName }` | Causal graph for a country: `nodes` (+`actors[]`), causal `edges`, shared-actor `backbone[]` |\n| `event_dossier` | None | `{ countryName, threadId, hops }` | AI-legible dossier: focal event's k-hop subgraph + provenance + genesis + reasoning_contract (see `EVENT_DOSSIER_SPEC.md`) |\n| `dossier_analysis` | None | `{ countryName, threadId, hops }` | Builds the dossier + has DeepSeek reason over it under the honesty contract → `{ dossier, analysis }` |\n| `world_overview` | None | `{}` | `{ situations, links }` — per-country situation stats incl. `peak`/`earliest`/`latest` (paginated SYSTEMS# scan) + cross-country shared-actor `links` (ambient excluded), for the World Overview timeline |\n| `daily_brief` | None (early access) | `{ dateKey }` | Daily Intelligence Brief for a specific date |\n| `weekly_brief` | None | — | Latest **published** Weekly Signals Brief (`{ format:'signals', weekOf, asOf, signals[], watch[] }`); null until one is published via weekly/review.js. Powers `/weekly-brief` |\n| `weekly_markets` | None | — | Latest **published** Weekly Markets Report (`{ weekOf, asOf, movers[], excluded[] }`); null until one is published via weekly-markets/review.js. Powers `/economy`'s \"This week\" mode (`?view=week`); `/weekly-markets` redirects there |\n| `pair_analysis` | None (early access) | `{ pair: \"slug\" }` | Bilateral relationship analysis for a country pair |\n| `pair_analyses_list` | None (early access) | — | All pair analyses (DDB Scan, sorted list) |\n| `economic_impact` | None (early access) | `{ threadId }` | Per-thread economic disruption analysis |\n| `economic_impact_list` | None (early access) | — | All economic-impact records (DDB Scan) |\n| `economic_top_movers` | None (early access) | — | Highest-magnitude economic-impact threads |\n| `prediction_track_record` | None | — | Forecast calibration scoreboard (DDB Scan of `GlobalPerspectivePredictionLog`): totals, Brier score, calibration buckets, recently-resolved triggers. Honest empty state until human-confirmed verdicts exist — see [Prediction calibration](#prediction-calibration-track-record) |\n| `markets_global` | None (early access) | — | Global FX / rates / commodities / equities / crypto snapshot, **plus an additive `series` map (`{ [INSTRUMENT_ID]: { spark:[≤20 daily closes], change:%vs-yesterday } }`) built from the `HISTORY#` rows — powers the `/economy` watchlist mini-sparklines + day-over-day change pills** |\n| `markets_country` | None (early access) | `{ countryName }` | Country macro snapshot (GDP, CPI, reserves, etc.) |\n| `markets_history` | None (early access) | `{ symbol, days }` | Per-instrument price history `[{date, value}]` for sparklines — resolves `symbol` across commodities / rates / equities / crypto / FX (was FX-only before 2026-05-26) |\n\n**All content actions are public** (no auth required). The billing-gated `user_profile` / `portal_session` actions and `resolveUserTier()` were removed 2026-06-01 along with the rest of the subscription stack — see [Lambda #12](#) below. The generic `verifyFirebaseToken` helper remains for future per-user features (currently only the separate `newsSavedItems` Lambda uses Firebase JWT).\n\n**CORS origins:** `benben05059997.github.io`, `globalperspective.net`, `www.globalperspective.net`, `localhost:5173`, `127.0.0.1:5173`\n\n**Key env vars:**\n| Variable | Notes |\n|----------|-------|\n| `TOPICS_DDB_TABLE` | Required |\n| `SUMMARIZE_PREDICT_TABLE` | Required |\n| `MARKETS_DDB_TABLE` | Required (markets actions; default `GlobalPerspectiveMarkets`) |\n| `USERS_DDB_TABLE` | Required (for tier lookup) |\n| `MAPBOX_GEOCODING_KEY` | Required |\n| `FIREBASE_PROJECT_ID` | Required (JWT verification) |\n| `PADDLE_API_KEY` | Portal sessions only — unset in deployed `newsSensitiveData-dev` while billing is dormant |\n| `TOPICS_CACHE_MAX_AGE_SECONDS` | Default: `9000` |\n\n---\n\n### 6. `newsPostLinkedIn`\n**Path:** `amplify/backend/function/newsPostLinkedIn/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeLinkedIn` — `cron(20 */3 * * ? *)` (every 3 hours at :20)\n\n**What it does:**\n1. Reads `latest` topics + AI summaries from DynamoDB\n2. Generates platform-specific post copy per topic\n3. Checks `SOCIAL_POSTS_TABLE` to skip already-posted topics\n4. Posts to **LinkedIn + Bluesky** (the long-tail platforms — X/Twitter, Threads, Mastodon, Telegram, Farcaster — were cut 2026-05-18; `buildPlatformList()` now wires only these two)\n5. Records each post with 30-day TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_PERSON_ID`, `BLUESKY_IDENTIFIER`, `BLUESKY_APP_PASSWORD`, `SOCIAL_POSTS_TABLE`, `MAX_POSTS_PER_RUN` (default: 5), `MAX_POSTS_PER_DAY` (default: 100)\n\n> ⚠️ **`LINKEDIN_ACCESS_TOKEN` expires every 60 days** (shared with `linkedInAutoPost` #10). On lapse, both Lambdas fail every run with `401 EXPIRED_ACCESS_TOKEN` while Bluesky keeps working. Last refreshed 2026-06-22 → next ~2026-08-21. **Refresh procedure (token-generator UI, no client secret) → `BACKEND_GUIDE.md` \"LinkedIn token refresh runbook\".** Nothing alerts on this today.\n\n---\n\n### 7. `newsPostDevTo`\n**Path:** `amplify/backend/function/newsPostDevTo/src/index.js`\n**Trigger:** EventBridge Scheduler — `InvokeDev` — `cron(0 23 * * ? *)`, **timezone `Asia/Tokyo`** → 23:00 JST = **14:00 UTC daily** (the scheduler is NOT in UTC; live briefs show `generatedAt ≈ 14:01 UTC`, confirming this)\n\nPosts a daily AI-written summary article to [Dev.to](https://dev.to).\n\n**What it does:**\n1. Reads `latest` topics from Topics Table\n2. Calls OpenRouter AI (`AI_MODEL`, default `deepseek/deepseek-v4-flash:free`) to generate a long-form Dev.to article\n3. Checks `SOCIAL_POSTS_TABLE` to skip if already posted today\n4. Posts to Dev.to via API; records post with 90-day TTL\n\n**Key env vars:** `DEVTO_API_KEY`, `OPENROUTER_API_KEY`, `AI_MODEL` (default `deepseek/deepseek-v4-flash:free`), `TOPICS_DDB_TABLE`, `SOCIAL_POSTS_TABLE`, `SITE_URL`\n\n> **Note:** A deploy.zip is staged at `amplify/backend/function/newsPostDevTo/deploy.zip` — needs manual upload to AWS.\n\n---\n\n### 8. `newsPairIntelligence`\n**Path:** `amplify/backend/function/newsPairIntelligence/src/index.js`\n**Trigger:** Manual only — no EventBridge schedule. Invoke with `{\"pair\":[\"Country A\",\"Country B\"],\"forceRegenerate\":true}` or `{}` for default 10 pairs\n**Deployed:** 2026-04-18\n\nBilateral relationship analysis between country pairs.\n\n**What it does:**\n1. Default run: analyzes 10 predefined pairs; manual run: single pair specified in payload\n2. Reads 30-day archive; deduplicates events by Jaccard title similarity\n3. Loads `country_facts.json` editorial layer + existing country intelligence + thread analyses\n4. Calls the LLM to generate: `pairTitle`, `currentState`, `timeline`, `trajectory` (3 scenarios), `rootDriver` (3 layers), `predictions`, `watchItems`\n5. Writes to `SUMMARIZE_PREDICT_TABLE` at `PAIR#{slug}` / `PAIR_ANALYSIS`\n\n> **Provider note:** The source default still reads Grok and the migration plan's Phase 2 checkbox was never ticked, but the **deployed** function's env vars point at DeepSeek (`GROK_MODEL=deepseek-chat`, `GROK_API_URL=https://api.deepseek.com/chat/completions`, verified 2026-05-26). Manual-invoke only — not on any EventBridge schedule.\n\n**Frontend:** none — the PairListPage/PairPage components and their `/weekly/pairs` + `/weekly/pair/:slug` routes were deleted in the \"Cut: orphans\" cleanup. The backend data + API actions remain; nothing renders them.\n**API actions:** `pair_analysis` (single pair by slug) + `pair_analyses_list` (all pairs, DDB Scan) — still served, currently unconsumed by the UI\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — deployed function holds **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BRAVE_SEARCH_API_KEY`\n\n---\n\n### 9. `newsSystemsAnalysis`\n**Path:** `amplify/backend/function/newsSystemsAnalysis/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsSystemsAnalysis` — `cron(15 7 * * ? *)` (daily 07:15 UTC)\n**Deployed:** 2026-04-25. IAM logging fixed 2026-04-27 (log group now exists). Coverage gate `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 → **12 countries** (`Iran,Israel,United States,Venezuela,China,Japan,Ukraine,Russia,France,Germany,Democratic Republic of the Congo,South Africa`; 56 eligible; daily cron keeps them fresh)\n\nCross-domain causal relationship analysis. Maps how events across categories connect with time lags and confidence.\n\n**What it does:**\n1. Reads 30-day archive entries; groups by country\n2. Identifies threads (2+ articles per threadId) per country; loads thread analyses\n3. For each country: builds nodes from threads, calls **DeepSeek V4** with anti-hallucination prompt\n4. The LLM returns nodes (with `actors[]`) + causal edges. Validation: all IDs must be real, edges cite real topicIds, confidence calibrated\n5. Dropped edges: unknown IDs, self-loops, 0 citations, confidence downgraded if citation count misses threshold. Surviving causal edges tagged `class:'causal'`\n6. **Backbone layer (deterministic, no LLM):** `buildBackboneEdges` links threads sharing SPECIFIC named actors (weight = # shared); excludes \"ambient\" actors — the country itself + any actor in ≥60% of nodes — so it connects the graph without a topic-wide hairball. Added 2026-07-01 (per `SPIDER_BUILD_SPEC.md` / `SPIDER_WEB_MODEL_PLAN.md`)\n7. Writes to `SUMMARIZE_PREDICT_TABLE` at `SYSTEMS#{countryName}` / `SYSTEMS_ANALYSIS` (14-day TTL)\n\n**Output shape:**\n```json\n{\n \"nodes\": [{\"threadId\", \"category\", \"peakDate\", \"actors\": [\"Israel\",\"Strait of Hormuz\"], \"summary\"}],\n \"edges\": [{\"from\", \"to\", \"lagDays\", \"mechanism\", \"confidence\", \"citedEntries\", \"class\":\"causal\"}],\n \"backbone\": [{\"from\", \"to\", \"class\":\"backbone\", \"relation\":\"shared_actor\", \"sharedActors\":[…], \"weight\", \"directed\":false}]\n}\n```\nTwo layers: `edges` = sparse causal overlay (💭 model judgment); `backbone` = dense shared-actor web (✅ factual). Frontend draws backbone as solid always-on lines, causal as a dashed toggle (`SpiderDemo.jsx`).\n\n**Coverage (current):** 12 countries (widened 2026-07-01; 56 eligible) — Iran, Israel, United States, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa. Each carries `nodes`(+`actors[]`), causal `edges`, and shared-actor `backbone[]`. Consumed by `/spider-demo` (World + Country tiers) and the `event_dossier`/`dossier_analysis`/`world_overview` proxy actions.\n\n**Key env vars:** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values in production), `SYSTEMS_TOP_N` (default: 5), `SYSTEMS_TEST_COUNTRIES`\n\n---\n\n### 10. `linkedInAutoPost`\n**Path:** `amplify/backend/function/linkedInAutoPost/src/index.js`\n**Trigger:** EventBridge Scheduler — `LinkedinThreadsDaily` — `cron(30 7/12 * * ? *)` (every 12 hours: 07:30, 19:30 UTC)\n\nIntelligent scheduled LinkedIn poster — distinct from `newsPostLinkedIn` (manual/multi-platform).\n\n**What it does:**\n1. Scans `SUMMARIZE_PREDICT_TABLE` for thread analyses (`THREAD_ANALYSIS`) and country intelligence (`COUNTRY_INTELLIGENCE`)\n2. Scores items by trend (rising/stable/fading) and risk level (critical/elevated/moderate/low)\n3. Deduplicates against `SOCIAL_POSTS_TABLE`\n4. Posts highest-scoring eligible item to LinkedIn; records with TTL\n\n**Key env vars:** `LINKEDIN_ACCESS_TOKEN` (60-day expiry — shares the token + refresh runbook with `newsPostLinkedin` #6, see that note), `LINKEDIN_PERSON_ID`, `SUMMARIZE_PREDICT_TABLE`, `SOCIAL_POSTS_TABLE`\n\n---\n\n### 11. `newsCountryFactsUpdater`\n**Path:** `amplify/backend/function/newsCountryFactsUpdater/src/index.js`\n**Trigger:** EventBridge Scheduler — `Fact` — `cron(0 5 * * ? *)` (daily 05:00 UTC)\n**Deployed:** 2026-04-18 (Phase 2 complete)\n\nKeeps country facts in DynamoDB current without manual editing.\n\n**What it does:**\n1. Fetches head-of-state/government data from **Wikidata** via SPARQL query\n2. Fetches active conflicts from **ACLED API** (approval pending)\n3. Detects leadership changes vs. previously stored facts\n4. Stores results in `SUMMARIZE_PREDICT_TABLE` at `FACTS#{countryName}` / `COUNTRY_FACTS` (90-day TTL)\n5. Supports partial updates for specific countries via payload\n\n**DDB key:** `FACTS#{countryName}` / `COUNTRY_FACTS`\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `ACLED_USERNAME` + `ACLED_PASSWORD` (OAuth token flow against `acleddata.com/oauth/token` — there is **no** `ACLED_API_KEY`)\n\n---\n\n### 12. `newsStripeWebhook` (name was legacy — handled Paddle) ❌ REMOVED 2026-06-01\n**Source kept for reference:** `amplify/backend/function/newsStripeWebhook/src/index.js`\n\n> **REMOVED 2026-06-01 — billing teardown complete.** The deployed Lambda, its public Function URL (`https://tu2abnue3kefs2lkeczezoez3m0fzztr.lambda-url.ap-northeast-1.on.aws/`, 0 invocations in 30 days), its IAM role `newsStripeWebhook-role-kercpkn5`, and the orphaned per-function exec policy were all deleted via CLI. The `resolveUserTier()` logic and the `user_profile` / `portal_session` proxy actions were stripped from `newsSensitiveData` in the same pass. Source remains in-repo for reference only; nothing invokes it. Subscriptions are not coming back — do not rebuild on this. See [[project-billing-deprecated]].\n\n---\n\n### 13. `newsMarketsData`\n**Path:** `amplify/backend/function/newsMarketsData/src/index.js`\n**Triggers:** EventBridge Rules — `MarketsDataHourly` (`rate(1 hour)`), `MarketsYieldsDaily` (`cron(0 6 ? * MON-FRI *)`), `MarketsMacrosWeekly` (`cron(0 2 ? * SUN *)`)\n\nFree economic-data ingest — **no LLM**, all free feeds.\n\n**What it does:**\n1. **FX** — Frankfurter (ECB rates, no key), hourly\n2. **Bond yields** — FRED (free key), daily on weekdays\n3. **Commodities + equities** — **Yahoo Finance** (`chart/<sym>?interval=1d`, no key, JSON) / crypto via CoinGecko, hourly. **Migrated off Stooq 2026-06-23** (commit `51ef532`): Stooq put all its CSV endpoints behind a JS proof-of-work bot wall and removed `/q/l/` (404 site-wide), so a server-side fetch can no longer read it — commodities + equities went null in prod until this swap. `fetchYahooQuote()` + the `YAHOO_COMMODITIES`/`YAHOO_EQUITIES` maps (the same maps `seed_history` already used; VIX was already Yahoo). Priced universe (~51 instruments, the spine behind `/economy` — see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): commodities (Brent `BZ=F`, WTI `CL=F`, gold `GC=F`, copper `HG=F`, DXY `DX-Y.NYB`, VIX `^VIX`, **natural gas** `NG=F`), bond yields, equity indices + Russell 2000 (**IWM** proxy), the **full 11 GICS sector SPDR ETFs** + thematic ETFs (defense/semis/gold-miners/**agriculture DBA**/**rare-earths REMX**), and BTC/ETH.\n4. **Country macros** — World Bank (no key), weekly\n5. Writes `LATEST` rows plus dated `HISTORY#` rows (for sparklines) to `MARKETS_DDB_TABLE`\n\n**Payload:** `{}` runs all sources appropriate for the time; `{ \"source\": \"fx\" | \"yields\" | \"macros\" | \"commodities\" | \"equities\" | \"crypto\" }` runs one; `{ \"source\": \"seed_history\" }` is a **one-time** backfill that downloads ~30 days of daily closes per instrument from **Yahoo Finance** (`chart?interval=1d&range=2mo`, sequential/throttled — Yahoo rate-limits bursts) and writes the past `HISTORY#YYYY-MM-DD` rows (35-day TTL, only dates < today, skips existing rows). The hourly cron appends today's row from the Yahoo LATEST quote, and TTL keeps the window at ~30 days. This is what powers the `/economy` sparklines + Key-levels. (Stooq, the original source for both LATEST quotes and history, is fully dead — see source #4 note above.)\n\n**Key env vars:** `MARKETS_DDB_TABLE` (default `GlobalPerspectiveMarkets`), `FRED_API_KEY`\n\n---\n\n### 14. `newsEconomicImpact`\n**Path:** `amplify/backend/function/newsEconomicImpact/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicImpact` — `cron(30 7 * * ? *)` (daily 07:30 UTC, after thread analysis)\n\nPer-thread economic disruption analysis — the \"Economic Disruption Layer.\" Uses **DeepSeek V4**.\n\n**What it does:**\n1. Reads 30 days of archive + each thread's `THREAD_ANALYSIS` and topic `SUMMARY` records\n2. For threads with an economic dimension, calls DeepSeek with a **closed instrument allowlist** (commodities incl. natural gas, rates, equity indices, GICS sector + thematic ETFs incl. agriculture/rare-earths, crypto; any unknown ticker → dropped), presented as a live-priced menu via `buildInstrumentTable`, plus a curated `economic_analogs.json` catalog of real past events with realized moves (the model picks the closest by mechanism; the frontend shows the analog's *actual* past move, never a forecast)\n3. Output is qualitative only — `direction` (up/down/mixed), `magnitude` (small/moderate/large), **never a fabricated %**; every claim must cite real `topicId`s (uncited claims dropped post-parse)\n4. Snapshots **actual** prices from `MARKETS_DDB_TABLE` (compute, don't generate)\n5. Writes tombstone records `{ hasImpact: false }` for threads with no economic dimension\n6. Writes to `SUMMARIZE_PREDICT_TABLE` at `ECON#THREAD#{threadId}` / `ECONOMIC_IMPACT` (21-day TTL)\n\n**Key env vars:** `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE`, `LLM_CONCURRENCY` (default `4`), `ECON_MIN_ENTRIES` (default `2`), `ECON_MAX_THREADS` (default `15`)\n\n---\n\n### 15. `newsEconomicQuality`\n**Path:** `amplify/backend/function/newsEconomicQuality/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerNewsEconomicQuality` — `cron(0 8 * * ? *)` (daily 08:00 UTC, after economic impact; aligned with Gemini quota reset)\n\nLLM-as-judge quality gate for economic-impact records. Deliberately a **different model family** from the producer (DeepSeek produces, **Gemini 2.5 Flash** judges) so judge errors are less correlated with producer errors.\n\n**What it does:**\n1. Scans recent `ECON#THREAD#` records (`hasImpact: true`, not judged in last 7 days)\n2. Builds a judge prompt, calls Gemini, parses 5-axis scores: `coherence`, `citation_fidelity`, `analog_match`, `severity_calibration`, `no_bs`\n3. Flags the record `is_low_quality: true` if any axis ≤ 2\n4. Writes `qualityScores` + `is_low_quality` + `quality_judged_at` back to the record\n\n**Key env vars:** `XAI_API_KEY` (legacy name — holds **Gemini** key), `GROK_MODEL` (= `gemini-2.5-flash`), `GROK_API_URL` (= Gemini OpenAI-compat endpoint), `INTER_CALL_DELAY_MS` (= `13000`), `MAX_TOKENS` (= `16000`, large because Gemini thinking tokens count against the cap), `QUALITY_MAX_RECORDS` (default `15`), `QUALITY_RECENT_DAYS` (default `7`), `SUMMARIZE_PREDICT_TABLE`\n\n---\n\n### 16. `newsSavedItems`\n**Path:** `amplify/backend/function/newsSavedItems/src/index.js`\n**Trigger:** User-triggered — Lambda Function URL (`window.SAVED_ITEMS_ENDPOINT`) or AppSync resolver (auto-detected). **No LLM.**\n\nPer-user save/bookmark store.\n\n**What it does:**\n1. Verifies a **Firebase JWT** (lightweight Node `crypto` + Google certs — no firebase-admin) on every action\n2. Actions: `save_item`, `unsave_item`, `get_saved_items`\n3. Item types allowed: `thread`, `country`, `daily`, `pair`. Caps: 500 items/user, 4KB metadata/item\n\n**DDB:** `SAVED_ITEMS_TABLE` — **PK** `uid`, **SK** `savedKey` (= `{itemType}#{itemId}`)\n\n**Key env vars:** `SAVED_ITEMS_TABLE`, `FIREBASE_PROJECT_ID`\n\n---\n\n### 17. `newsClientErrors`\n**Path:** `amplify/backend/function/newsClientErrors/src/index.js`\n**Trigger:** User-triggered — public Lambda Function URL (AuthType NONE; errors come from anonymous visitors). **No LLM.**\n**Built:** 2026-05-30. Roll-your-own passive error sink (no paid Sentry).\n\nReceives uncaught frontend errors and aggregates them into one counter row per fingerprint.\n\n**What it does:**\n1. Frontend `services/errorSink.js` (installed in `main.jsx`) listens for window `error` + `unhandledrejection`, and — via `reportBoundaryError` — React render crashes caught by the class `ErrorBoundary` around `<Routes>`. Fire-and-forget `fetch` POST, no-ops until `window.CLIENT_ERRORS_ENDPOINT` is set in `docs/config.js`.\n2. Lambda fingerprints each error (sha1 of message + normalized top stack frame) and `ADD count`s into one DynamoDB row, so a flood of identical errors collapses to a single row + sample.\n3. Abuse-bounded: 16KB body cap, per-field length caps, CORS locked to the two site origins.\n\n**DDB:** `GlobalPerspectiveClientErrors` — **PK** `errKey` (= `day#hash`), TTL 30d.\n**Read-back:** `node scripts/errors.mjs [--days N]` — source-map-resolves the top frame (maps are `build.sourcemap:'hidden'`, stripped from `docs/` on deploy).\n**Key env vars:** `CLIENT_ERRORS_TABLE`, `ERROR_TTL_DAYS`. **Role:** `newsClientErrors-role` (dynamodb:UpdateItem on the table only).\n\n---\n\n### 18. `newsFreshnessMonitor`\n**Path:** `amplify/backend/function/newsFreshnessMonitor/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerFreshnessMonitor` — `cron(30 0/2 * * ? *)` (every 2h at :30). **No LLM.**\n**Built:** 2026-06-01. Data-freshness dead-man's-switch.\n\nThe passive complement to the on-demand `scripts/` checks: catches a pipeline stall that happens on a schedule (the active checks' blind spot).\n\n**What it does:**\n1. Hits the public proxy `?action=topics`, reads `asOf` (= latest topics `updatedAt`).\n2. Alerts via SNS if content age > `STALE_HOURS` (=9; the content pipeline runs every ~4h, so 9h tolerates ~2 missed cycles) **or** the proxy is unreachable / returns no timestamp (so it doubles as a read-path uptime check, no DDB coupling).\n3. Honest-failure: only alerts on a real problem, never a fake \"all clear\".\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `PROXY_URL`, `SNS_TOPIC_ARN`, `STALE_HOURS`, `SITE_URL`. **Role:** `newsFreshnessMonitor-role` (sns:Publish only).\n\n---\n\n### 19. `newsErrorDigest`\n**Path:** `amplify/backend/function/newsErrorDigest/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerErrorDigest` — `cron(15 0/6 * * ? *)` (every 6h). **No LLM.**\n**Built:** 2026-06-01. The alerting/triage layer over the `newsClientErrors` sink (#17).\n\nTurns the sink's passive capture into a push alert without a paid Sentry.\n\n**What it does:**\n1. Scans `GlobalPerspectiveClientErrors`, folds rows to per-fingerprint totals.\n2. Diffs against the prior run (stored in one `DIGEST#STATE` row in the same table).\n3. Alerts via SNS ONLY on **new** or **spiking** (Δ ≥ `SPIKE_MIN_DELTA`=5) fingerprints — first run just baselines; known errors never re-alert (the alert-fatigue guard).\n\n**Alerts → SNS topic `GlobalPerspectiveAlerts`** → email. **Key env vars:** `CLIENT_ERRORS_TABLE`, `SNS_TOPIC_ARN`, `SPIKE_MIN_DELTA`. **Role:** `newsErrorDigest-role` (sns:Publish + dynamodb Scan/Get/Put on the errors table).\n\n---\n\n### 20. `newsPredictionResolver`\n**Path:** `amplify/backend/function/newsPredictionResolver/src/index.js`\n**Trigger:** EventBridge Rule — `TriggerPredictionResolver` — `cron(0 9 * * ? *)` (daily 09:00 UTC).\n**Built:** 2026-06-02. Phase 2 of the prediction-calibration pipeline — see [Prediction calibration](#prediction-calibration-track-record).\n\nThe **proposal** half of hybrid resolution. Reads `GlobalPerspectivePredictionLog` (status=`open`), finds dated triggers whose `deadline` ≤ today with no `proposal`/`finalVerdict`, grounds each against **Brave Search**, and asks the LLM (DeepSeek, `response_format: json_object`) for a fired/not_fired/unclear verdict + citation. Attaches `trigger.proposal = { verdict, confidence, citation, reasoning, sources, proposedAt }` + `needsConfirm=true` and writes the snapshot back. **Never finalizes** — a human confirms via `predictions/review.js` (no public auth surface). `MAX_RESOLVE_PER_RUN=40`, `LLM_CONCURRENCY=3`. Honest \"unclear\" when the news record is ambiguous rather than guessing.\n\n**Key env vars:** `PREDICTION_LOG_TABLE`, `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (hold DeepSeek values, see [[feedback-misleading-grok-naming]]), `BRAVE_SEARCH_API_KEY`.\n\n---\n\n### 21. `newsBreakingAlert` ⚠️ BUILT 2026-06-10 — NOT DEPLOYED (dry-run)\n**Path:** `amplify/backend/function/newsBreakingAlert/src/index.js`\n**Trigger (planned):** EventBridge — `cron(15 */4 * * ? *)` (:15 each 4h cycle, after `NewsProjectInvokeAgentLambda` writes the analysis). **No schedule, no table, no email yet** — code exists in-repo, runs in `DRY_RUN` mode only.\n\nBreaking-news email channel — **Component 4** of the recommendations/digest work (`RECOMMENDATIONS_AND_DIGEST_PLAN.md`); full design in `BREAKING_ALERTS_PLAN.md`. v1 is a **broadcast** alert (global significance, not personalized), pairing a breaking headline with the site's own analysis. Detects deterministically, then proposes for human review — **never auto-sends**.\n\n**What it does:**\n1. Groups `latest.topics[]` into stories by `threadId`; scores each with the **deterministic, no-LLM** `significance.js` on four real signals: popularity (`sources.length`), breadth (concurrent angles), max country `riskScore` (0–100, from `COUNTRY_INTELLIGENCE`), and economic `magnitude` (from `ECONOMIC_IMPACT`). Most cycles clear nothing — **silence is the correct output** ([[feedback-no-misinformation-fallback]]).\n2. For the top story above threshold (deduped 5d, one/run), assembles the email from real records — `SUMMARY` (*What happened*), parsed `TRACE_CAUSE` JSON (*How we got here*: trigger → building → root + underreported angle + Signal-vs-Noise), `PREDICTION` (*Our read*), `ECONOMIC_IMPACT` (market pill), sources.\n3. `render.js` returns `{subject, text, html}` — brand-styled, email-safe table layout + inline CSS, XSS-escaped, empty sections omitted.\n4. Writes a `status:'proposed'` row to `GlobalPerspectiveBreakingAlerts` (doubles as the dedupe anchor). As of 2026-06-26 the proposal also persists the **structured story** (`story:{summary,prediction,traceCause,economic,sources}`) plus flat fields the web surface reads — `category`, `regions`, `leadTopicId`, the **real** `threadId` (null when un-threaded), `hasArc` (a real multi-entry thread with `THREAD_ANALYSIS`), `outletCount`/`sourceCount` — so `/breaking/:id` reads clean fields instead of re-parsing the email. `verifyStory()` is a **stub seam** for the Phase-3 LLM verify (Gemini judges the DeepSeek-written analysis, like `newsEconomicQuality`).\n\n> **Web surface (LIVE 2026-06-26):** confirmed alerts now have an on-site home at `/breaking` (feed) + `/breaking/:id` (detail) — served by `newsRecommend`'s `list_alerts`/`get_alert` (see Lambda #22), surfaced via the nav bell + a fresh-only `BreakingStrip` on Home/Map. This replaced the old bell→`/weekly/thread/:id` link, which mis-resolved (a breaking story is a point-in-time snapshot, not a narrative thread; the linked id was often a topic id or a single-entry thread). `newsBreakingAlert` itself stays dry-run/un-deployed — existing confirmed records render via the saved-email-text fallback in `get_alert`; redeploying it later upgrades future alerts to the richer structured layout.\n\n**Email provider: Resend** (chosen over SES 2026-06-10 for DX + no sandbox-approval wait) — `sendEmail.js` is the single provider seam (`fetch`, no npm dep, key from `RESEND_API_KEY`).\n\n**Human-in-the-loop (no public auth surface, mirrors `predictions/review.js`):** `breaking/review.js` — operator reviews each proposal, **adds their own words** (an editor note that leads the email), confirms (`status:'confirmed'`) or rejects. `breaking/send-test.js` renders a sample and sends it via Resend (`RESEND_API_KEY=re_xxx node breaking/send-test.js`).\n\n**Key env vars (planned):** `TOPICS_DDB_TABLE`, `SUMMARIZE_PREDICT_TABLE`, `BREAKING_ALERTS_TABLE`, `RESEND_API_KEY`, `SITE_URL`, `DRY_RUN` (default `true`), `SIGNIFICANCE_THRESHOLD`, `DEDUPE_DAYS`.\n\n---\n\n### 22. `newsRecommend`\n**Path:** `amplify/backend/function/newsRecommend/src/index.js`\n**Trigger:** Lambda **Function URL** (`window.USER_PREFS_ENDPOINT`, auth NONE + CORS) and direct invoke (digest cron). **No LLM.** Built 2026-06-05 (Component 1 of `RECOMMENDATIONS_AND_DIGEST_PLAN.md`); prefs actions added + Function URL created 2026-06-10.\n\nSingle owner of `GlobalPerspectiveUserPrefs`. Two responsibilities:\n1. **Recommendations** — content-based ranking of `latest.topics[]` for a user (shared `scoring.js`; personalized via the user's `SavedItems` interest profile when a JWT is present, else \"Trending\"). On-site rail not yet wired.\n2. **Notification preferences** (added 2026-06-10) — JWT-gated `get_prefs` / `set_prefs`:\n - `get_prefs` → `{ breakingOptIn, digestOptIn, digestCadence }` (defaults OFF — opt-in/GDPR).\n - `set_prefs` `{ breakingOptIn?, digestOptIn?, digestCadence? }` → writes those + reserves compliance fields (`email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`) for when email delivery goes live; never clobbers `interestProfile`.\n - Both require `Authorization: Bearer <firebase-id-token>` (`uid` = token sub); no token → 401. Powers the Account → Notifications tab.\n3. **In-app notification feed** (added 2026-06-10; enriched 2026-06-26) — **public** `list_alerts` (no auth): scans `GlobalPerspectiveBreakingAlerts` for `status ∈ {confirmed, sent}`, newest-first, returns `[{ id, threadId, title, url(→/breaking/:id), category, regions, reasons, economic, outletCount, sourceCount, at }]` (or `[]`). Powers the nav bell, the `/breaking` feed, and the `BreakingStrip`. Read-state is client-side (localStorage), so no per-user write.\n4. **Single breaking alert** (added 2026-06-26) — **public** `get_alert` `{ payload:{ id } }` (no auth): `GetCommand` by `alertKey`, returns the full alert **only when `status ∈ {confirmed, sent}`** (never a raw proposal). Surfaces the structured `story` when present, else `fallbackText` = the saved email body for legacy records; includes `hasArc`/`threadUrl` so the page gates the arc link. Powers `/breaking/:id` (`useBreakingAlert`). Returns `{ alert: null }` for unknown/unconfirmed ids → honest not-found.\n\n**Key env vars:** `TOPICS_DDB_TABLE` (=`NewsCache`), `SAVED_ITEMS_TABLE`, `USER_PREFS_TABLE` (=`GlobalPerspectiveUserPrefs`), `BREAKING_ALERTS_TABLE`, `SITE_URL`, `FIREBASE_PROJECT_ID`. **Role:** `newsRecommend-role` (Get/Update/Put on UserPrefs, read on NewsCache + SavedItems, Scan + GetItem on BreakingAlerts).\n\n---\n\n### 23. `newsWeeklyBrief`\n**Path:** `amplify/backend/function/newsWeeklyBrief/src/index.js`\n**Trigger:** EventBridge `TriggerWeeklyBrief` — `cron(0 6 ? * SUN *)` (Sundays 06:00 UTC). Built 2026-06-10. Uses **DeepSeek V4**.\n\nGenerates the **Weekly Signals Brief** — a *signals digest* (NOT a synthesized essay; distinct from the daily `DAILY_BRIEF`). The pivot to signals followed research into how rigorous weeklies work (Economist \"world this week\", ISW, Semafor): surface discrete signals, keep fact separate from judgment, never manufacture a grand thesis (an automated synthesizer overreaches).\n\n**What it does:**\n1. Reads the last 7 days of archive entries; groups by `threadId`; selects top threads + top countries.\n2. Computes **deterministic** per-signal data: `riskLevel`/`riskScore`, `region`, `asOf`, `sources` (real article links from our data).\n3. Calls DeepSeek for ONLY the per-signal **text** (`lede`, `fact`, `soWhat`) under strict epistemic rules (verb-mark claims; calibrated so-what; no thesis; no forced cross-links; no invented specifics) + a `watch` list. The risk/sources/dates are never the LLM's.\n4. Joins them → writes `WEEKLY_BRIEF#{weekKey}` / `WEEKLY_BRIEF`, `format:'signals'`, `status:'draft'` (180-day TTL).\n\n**Human approval (gate kept):** the schedule generates a **draft**; a human publishes via `weekly/review.js` (`status → published`) before it's served. Generation scheduled; publishing manual.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values), `TOPICS_DDB_TABLE` (=`NewsCache`), `SUMMARIZE_PREDICT_TABLE`, `MAX_TOKENS`, `WEEKLY_TOP_THREADS`, `WEEKLY_TOP_COUNTRIES`. **Role:** `newsWeeklyBrief-role` (read NewsCache, Get/Put SummarizeAndPredict).\n\n### 24. `newsSourceAudit` — source-truth dead-man's-switch (SHIPPED 2026-06-15)\n**Path:** `amplify/backend/function/newsSourceAudit/src/index.js`\n**Trigger:** EventBridge `newsSourceAuditDaily` — `cron(30 8 ? * * *)` (daily 08:30 UTC).\n\n**What it does:** the automated half of the **source-truth layer** (`ANALYSIS_SOURCE_TRUTH_PLAN.md`) — faithfulness checks verify the *output*; this verifies the *input* is trustworthy and that OUR summary didn't drift from the source. Self-contained port of `quality/analysis/source_check.mjs`. Per run, for the top `AUDIT_N` (=6) live topics: **L1** deterministic source-robustness from `sources[]` (single-source / low-tier ⇒ \"unverified\"); **L1.5** fetches the full article(s) from up to 3 cited outlets and asks a DeepSeek auditor whether the cached `SUMMARY` drifted (hedge-strip / invented result / added framing). If confirmed-drift ≥ `DRIFT_ALERT_THRESHOLD` (=2) it **SNS-publishes an alert** to `GlobalPerspectiveAlerts`; always logs to CloudWatch.\n\n**Key env vars:** `XAI_API_KEY`/`GROK_API_URL` (DeepSeek, copied from newsEconomicImpact), `AUDIT_MODEL` (=`deepseek-v4-pro`), `PROXY_ENDPOINT`, `SNS_TOPIC_ARN`, `AUDIT_N`, `DRIFT_ALERT_THRESHOLD`. **Role:** `newsSourceAudit-role` (basic-exec logs + `sns:Publish` to GlobalPerspectiveAlerts). Reads content via the public proxy (no DDB perms).\n\n---\n\n### 25. `newsWeeklyMarkets` — Weekly Markets Report (SHIPPED + first report PUBLISHED 2026-06-29)\n**Path:** `amplify/backend/function/newsWeeklyMarkets/src/index.js`\n**Trigger:** EventBridge Rule `TriggerWeeklyMarkets` — `cron(30 8 ? * SUN *)` (Sundays 08:30 UTC, after the daily econ-impact pipeline so coverage is fresh). **Generates a DRAFT only** — a human still publishes via `weekly-markets/review.js` (the gate is kept). Uses **DeepSeek V4** (coverage notes) + **Perplexity `sonar`** (web-context fallback). Plan: `WEEKLY_MARKETS_PLAN.md`.\n\nThe **price-first** weekly markets wrap — \"what moved this week and why\" — the instrument→explanation counterpart of `/economy` (news→instrument). Sibling of `newsWeeklyBrief`: generate draft → human approve → publish → serve.\n\n**What it does:**\n1. Scans `GlobalPerspectiveMarkets` `HISTORY#` rows for the tracked universe and computes each instrument's ~1-week % move **deterministically** (never via LLM — same transpose the `markets_global` serve action uses). Guards: `MAX_ANCHOR_AGE_DAYS=14` (reject a stale week-ago anchor across a data gap — caught a bogus COPPER −99%), `MIN_MOVE_PCT` (drop flat rows), ≥100% sanity ceiling. Thin/gappy instruments → `excluded[]` (\"history accruing\").\n2. Selects the top movers by |%|. Per mover, three **honesty-layered** grounding tiers, kept visibly distinct: **coverage** (recent `ECON#THREAD#`/`ECONOMIC_IMPACT` records citing it, ~9-day window → a short DeepSeek note grounded only in our headlines), **web** (Perplexity `sonar` self-searches + cites, framed as candidate drivers not causation), or honest **none** (\"no clear driver found\"). Every tier degrades gracefully — an LLM/key error never blocks the report or fabricates a cause.\n3. **Quality benchmark (LLM-as-judge, added 2026-06-29):** each DeepSeek coverage note must clear a **Gemini** judge (a DIFFERENT model family → less-correlated errors, mirrors `newsEconomicQuality`) on three binary axes — **faithful** (grounded in the cited coverage, uses the most-relevant headline, no invented specifics), **coherent** (the cited cause explains a move in the *actual* direction — e.g. risk-off → VIX *up*, so a VIX *down* note citing a sell-off FAILS), **informative** (concrete reason, not vague filler). Fail any → the note is **dropped to \"no clear driver found\"** (honest blank beats weak/backwards analysis); the rejected note + reason are kept on the record (`noteJudge`/`rejectedNote`) for review. Fail-open only if no judge key is set. Caught VIX (backwards logic) + REMX (tangential grounding + filler) on the first live run.\n4. Writes `WEEKLY_MARKETS#{weekKey}` / `WEEKLY_MARKETS`, `status:'draft'` (180-day TTL). A human publishes via `node weekly-markets/review.js` (status → published) before it's served; served by the `weekly_markets` proxy action → rendered by `WeeklyMarketsView` in `/economy`'s **\"This week\" mode** (`?view=week`; `/weekly-markets` redirects there), via `useWeeklyMarkets`. **Consolidated into `/economy` 2026-06-30** (commit `41f2b5a`): Today/This-week toggle, standalone page → redirect permalink, \"Markets\" nav item removed.\n\n**Key env vars:** `SUMMARIZE_PREDICT_TABLE`, `MARKETS_DDB_TABLE` (=`GlobalPerspectiveMarkets`), `XAI_API_KEY`/`GROK_API_URL`/`GROK_MODEL` (legacy names — hold **DeepSeek** values, the note producer), `JUDGE_API_KEY`/`JUDGE_MODEL`(=`gemini-2.5-flash`)/`JUDGE_API_URL`/`JUDGE_DELAY_MS` (the **Gemini** quality judge — set), `PERPLEXITY_API_KEY` (web tier — **intentionally unset**, so uncovered movers stay \"no clear driver found\" by decision), `WEEKLY_TOP_MOVERS`, `MIN_MOVE_PCT`, `COVERAGE_WINDOW_DAYS`. **Timeout 300s** (extra Gemini judge calls). **Role:** `newsWeeklyMarkets-role` (Scan/Get on GlobalPerspectiveMarkets, Scan/Get/Put on SummarizeAndPredict).\n\n> **Markets-history backfill gotcha (2026-06-29):** `newsMarketsData` `seed_history` **skips dates whose `HISTORY#` row already exists** (`historyRowExists`). The Stooq→Yahoo outage left **null-valued** rows for 06-06..06-21, so seed_history wouldn't refill them. Fix = **delete the null rows, then re-run seed_history** (Yahoo `range=2mo` refills weekdays). This also repaired the `/economy` sparklines. Cross-page links to `/weekly-markets` live on Home/Map/Country/Thread (Economy tab) — these now redirect into `/economy?view=week` (hash preserved).\n\n---\n\n## Observability & Monitoring\n\nTwo layers, both roll-your-own (the user rejects paid Sentry on cost):\n\n- **Active / on-demand** — four standalone Playwright/static checks under `scripts/` (`smoke-test.mjs`, `link-crawl.mjs`, `auth-guard-check.mjs`, `contract-check.mjs`), run by hand against production. They detect the 8 bug classes in `BUG_PLAYBOOK.md`. They only run when invoked.\n- **Passive / 24-7** — the always-on complement that pushes alerts: `newsClientErrors` (#17) captures errors → `newsErrorDigest` (#19) alerts on new/spiking; `newsFreshnessMonitor` (#18) alerts on stale content / proxy down; **`newsSourceAudit` (#24)** alerts when our summaries fabricate / drift from their sources (source-truth dead-man's-switch). All alerts route to one SNS topic **`GlobalPerspectiveAlerts`** (`arn:aws:sns:ap-northeast-1:280362093938:GlobalPerspectiveAlerts`) → email (confirmed subscription). Dependency security via `.github/dependabot.yml` + the repo Dependabot-alerts toggle.\n\nExternal monitors that need the operator's own account (UptimeRobot, Google Search Console, optional Cloudflare Web Analytics) are documented in `BUG_PLAYBOOK.md` → \"Passive monitoring (24/7)\".\n\n---\n\n## DynamoDB Tables\n\n### Topics Table (`TOPICS_DDB_TABLE`)\n**PK:** `id` (String)\n\n| id | Description |\n|----|-------------|\n| `staging` | Topics being processed (written by newsInvokeGemini) |\n| `latest` | Active topics served to frontend |\n| `today-archive` | Today's snapshot (entries array) |\n| `archive#YYYY-MM-DD` | Daily archive entries |\n\n**Topic schema (inside `latest.topics[]`):**\n```json\n{\n \"id\": \"topic-hash\",\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\", \"China\"],\n \"threadId\": \"thread-slug-hash\",\n \"continues_topic\": \"prior-topic-id\",\n \"search_keywords\": [\"keyword1\"],\n \"sources\": [{ \"title\": \"...\", \"url\": \"...\", \"source\": \"reuters.com\" }]\n}\n```\n\n**Archive entry schema (inside `today-archive.entries[]` / `archive#YYYY-MM-DD.entries[]`):**\n```json\n{\n \"topicId\": \"topic-hash\",\n \"title\": \"Topic Title\",\n \"category\": \"politics\",\n \"regions\": [\"United States\"],\n \"threadId\": \"thread-slug-hash\",\n \"sources\": [...],\n \"ai\": { \"summary\": \"...\", \"prediction\": \"...\", \"trace_cause\": \"...\" },\n \"archivedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n---\n\n### Summary/Prediction Table (`SUMMARIZE_PREDICT_TABLE`)\n**PK:** composite string / **SK:** record type\n\n| PK | SK | Written by | Contents |\n|----|-----|-----------|---------|\n| `TOPIC#{topicId}` | `SUMMARY` | NewsProjectInvokeAgentLambda | Bullet-point key takeaways |\n| `TOPIC#{topicId}` | `PREDICTION` | NewsProjectInvokeAgentLambda | Chain-reaction analysis |\n| `TOPIC#{topicId}` | `TRACE_CAUSE` | NewsProjectInvokeAgentLambda | Root cause / historical context |\n| `THREAD#{threadId}` | `THREAD_ANALYSIS` | newsThreadAnalysis | threadTitle, storyArc, trajectory, rootCauseChain, watchQuestions |\n| `COUNTRY#{countryName}` | `COUNTRY_INTELLIGENCE` | newsCountryIntelligence | headline, situationSummary, crossThreadInsight, trajectory, riskSignals, riskLevel |\n| `PAIR#{pairSlug}` | `PAIR_ANALYSIS` | newsPairIntelligence | pairTitle, currentState, timeline, trajectory, rootDriver, predictions, watchItems |\n| `SYSTEMS#{countryName}` | `SYSTEMS_ANALYSIS` | newsSystemsAnalysis | nodes[] (+actors[]), causal edges[] (confidence, citations), shared-actor backbone[] (14-day TTL) |\n| `DAILY_BRIEF#{dateKey}` | `DAILY_BRIEF` | newsPostDevTo | Full daily intelligence brief text (90-day TTL) |\n| `WEEKLY_BRIEF#{weekKey}` | `WEEKLY_BRIEF` | newsWeeklyBrief (#23) | Weekly **signals** digest (`format:'signals'`): `signals[{kind('threat'|'development'),lede,fact,soWhat,riskLevel,riskScore,region,asOf,sources,related}]` + `watch[{event,date,stake}]`. LLM writes kind/lede/fact/soWhat only; risk/region/asOf/sources are deterministic. `kind` drives the chip — threats get a color-coded RISK chip, developments a neutral chip (so cooperative stories aren't shown as red risks). `status` draft→published via weekly/review.js (180-day TTL) |\n| `WEEKLY_MARKETS#{weekKey}` | `WEEKLY_MARKETS` | newsWeeklyMarkets (#25) | Weekly **markets** report (price-first): `movers[{instrumentId,name,changePct,direction,weekStart,weekEnd,grounding('coverage'|'web'|'none'),note,coverage[{threadId,headline,severity}],sources[{title,url}]}]` + `excluded[]` (thin/gappy history). The %/direction/anchors are **deterministic** (from MARKETS history, never LLM); the LLM writes only the per-mover `note`. `status` draft→published via weekly-markets/review.js (180-day TTL) |\n| `FACTS#{countryName}` | `COUNTRY_FACTS` | newsCountryFactsUpdater | Head of state/govt (Wikidata), active conflicts (ACLED), leadership change detection (90-day TTL) |\n| `ECON#THREAD#{threadId}` | `ECONOMIC_IMPACT` | newsEconomicImpact | direction, magnitude, instruments, analog, marketSnapshot, citations; quality scores added by newsEconomicQuality (21-day TTL) |\n| `TOPIC#{topicId}` | `RESEARCH_BRIEFING` | NewsProjectInvokeAgentLambda | Research briefing (first pass of two-pass prediction) |\n\n---\n\n### Users Table (`USERS_DDB_TABLE`)\n**PK:** `uid` (Firebase UID, String)\n\n```json\n{\n \"uid\": \"firebase-uid\",\n \"email\": \"user@example.com\",\n \"tier\": \"member\",\n \"paddleCustomerId\": \"ctm_xxx\",\n \"paddleSubscriptionId\": \"sub_xxx\",\n \"subscriptionStatus\": \"active\",\n \"createdAt\": \"2026-03-20T10:00:00.000Z\",\n \"updatedAt\": \"2026-03-20T10:00:00.000Z\"\n}\n```\n\n> **Live Polar + credits fields (written by `newsPolarBilling`/`newsAnalyze`):** `billingProvider:'polar'`, `polarCustomerId`, `polarSubscriptionId`, `currentPeriodEnd` (the `paddle*` fields above are dead legacy, never read). **Credits layer (built 2026-06-30):** `creditBalance` (Number, atomically decremented per non-allowance analysis run; topped up by credit-pack webhooks), `processedOrders` (String Set — webhook idempotency guard so an order can't double-credit), `analyzeMonth`/`analyzeCount` (member monthly free-run allowance counter, replaced the old daily `analyzeDay`/`ANALYZE_DAILY_CAP`).\n\n---\n\n### Social Posts Table (`SOCIAL_POSTS_TABLE`)\n**PK:** `topicId` + `platform`. Deduplication table for LinkedIn/Bluesky/Dev.to posts. TTL: 30 days (90 days for Dev.to).\n\n---\n\n### Markets Table (`MARKETS_DDB_TABLE`, default `GlobalPerspectiveMarkets`, ap-northeast-1)\n**PK:** `pk` / **SK:** `sk`. Written by `newsMarketsData`, read by `newsSensitiveData`.\n\n| pk | sk | Contents |\n|----|-----|---------|\n| `FX#USD` | `LATEST` / `HISTORY#YYYY-MM-DD` | USD-base FX rates |\n| `RATES#GLOBAL` | `LATEST` | US/UK/DE/JP bond yields |\n| `COMMODITIES#GLOBAL` | `LATEST` | Brent, WTI, gold, copper, VIX, DXY |\n| `EQUITIES#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | Indices + sector ETFs |\n| `CRYPTO#GLOBAL` | `LATEST` / `HISTORY#YYYY-MM-DD` | BTC, ETH + 24h change |\n| `MACRO#{country}` | `LATEST` / `HISTORY#YYYY-Q#` | GDP, CPI, reserves, debt/GDP, current account, unemployment |\n\nAll rows carry `asOf` timestamps + TTL. Honesty contract: never display stale data without labeling it.\n\n---\n\n### Saved Items Table (`SAVED_ITEMS_TABLE`, default `GlobalPerspectiveSavedItems`)\n**PK:** `uid` (Firebase UID) / **SK:** `savedKey` (= `{itemType}#{itemId}`). Written/read by `newsSavedItems`. Item types: `thread`, `country`, `daily`, `pair`.\n\n### User Prefs Table (`USER_PREFS_TABLE`, default `GlobalPerspectiveUserPrefs`, ap-northeast-1)\n**PK:** `uid` (Firebase UID). PAY_PER_REQUEST. Written/read by `newsRecommend` (#22). Holds two concerns on one row: `interestProfile` (M, recommendation cache) and notification prefs — `breakingOptIn`/`digestOptIn` (BOOL, default false), `digestCadence` (`weekly`|`daily`), plus compliance fields reserved for when email delivery is live: `email`, `consentAt`, `unsubToken`, `breakingVerified`, `digestVerified`. Read by the breaking-alert/digest senders to know who to target.\n\n### Client Errors Table (`GlobalPerspectiveClientErrors`, ap-northeast-1)\n**PK:** `errKey` (= `day#hash`), TTL 30d. Written by `newsClientErrors` (#17) — identical errors `ADD count` into one row (fingerprint = sha1 of message + normalized top frame). Scanned by `newsErrorDigest` (#19), which also stores its run baseline in a single `DIGEST#STATE` row here. Read back with `node scripts/errors.mjs`.\n\n### Prediction Log Table (`PREDICTION_LOG_TABLE`, default `GlobalPerspectivePredictionLog`, ap-northeast-1)\n**PK:** `PRED#{topicId}` / **SK:** `{YYYY-MM-DD}` (daily snapshot). **No TTL — immutable forecast record.** PAY_PER_REQUEST. See [Prediction calibration](#prediction-calibration-track-record). Written by `NewsProjectInvokeAgentLambda` (snapshot at generation, status=`open`), mutated by `newsPredictionResolver` (#20, attaches `proposal` to due triggers) and `predictions/review.js` (human sets `finalVerdict`, flips status→`resolved` when all dated triggers confirmed). Scanned by the `prediction_track_record` proxy action. Each item: `{ topicId, title, category, generatedAt, generationId, model, scenarios[{ label, probability(numeric midpoint), triggers[{ id, text, deadline, status, proposal?, finalVerdict?, confirmedAt?, confirmedBy? }] }], winners, losers, status }`.\n\n### Breaking Alerts Table (`BREAKING_ALERTS_TABLE`, default `GlobalPerspectiveBreakingAlerts`, ap-northeast-1)\n**PK:** `alertKey` (= `threadId`). PAY_PER_REQUEST, TTL on `ttl` (~14d). **Created 2026-06-10** (empty until the detector runs). Written by `newsBreakingAlert` (#21) as the dedupe anchor + proposal store; mutated by `breaking/review.js` (human confirm/reject); **scanned** by `newsRecommend` `list_alerts` + **`GetItem`-read** by `get_alert` (#22) for the bell, the `/breaking` feed, and the `/breaking/:id` detail page. Each item: `{ alertKey, status('proposed'|'confirmed'|'rejected'), score, reasons[], title, category, regions[], leadTopicId, threadId(real, nullable), hasArc, outletCount, sourceCount, story{summary,prediction,traceCause,economic,sources}, cycle, alertedAt, sent(bool), dryRun(bool), draft{subject,text,html}, editorNote?, verify{status,note}, ttl }`. (The structured `story`/flat fields were added 2026-06-26 for the web surface; records written before that carry only `draft` + `title` + `reasons`, and `get_alert` falls back to `draft.text`.) Part of the dry-run breaking-alerts system — see [Lambda #21](#21-newsbreakingalert-️-built-2026-06-10--not-deployed-dry-run) + `BREAKING_ALERTS_PLAN.md`.\n\n---\n\n## Prediction calibration / track record\n\nForecast accountability pipeline (built 2026-06-02). Every published prediction is logged with dated, falsifiable triggers; as deadlines pass, each trigger is scored fired/not_fired and the running Brier score + calibration are shown publicly at `/track-record`.\n\n**Three phases:**\n1. **Capture** — `NewsProjectInvokeAgentLambda` writes an immutable snapshot to `GlobalPerspectivePredictionLog` whenever it generates a `prediction` (scenarios → numeric probability midpoint + dated triggers). Never throws into the pipeline.\n2. **Resolve (hybrid)** — `newsPredictionResolver` (#20, daily 09:00 UTC) **proposes** verdicts grounded in Brave Search; a human **confirms** via `node predictions/review.js` (interactive / `--list` / `--accept-all`). No public auth surface — mirrors the economic-quality human-review pattern.\n3. **Score + surface** — `prediction_track_record` proxy action computes Brier (mean (p−outcome)² per resolved trigger, `unclear` excluded), calibration buckets (predicted vs actual fired-rate), and recent resolved list. `TrackRecordPage.jsx` (`/track-record`, hook `useTrackRecord`) renders it with an **honest empty state** — shows nothing rather than a fabricated score until human-confirmed verdicts exist ([[feedback-no-misinformation-fallback]]).\n\n**The calibration unit is the trigger** (binary, dated), not the scenario — a scenario's stated probability scores each of its triggers.\n\n---\n\n## Cloudflare Workers\n\nDomain is registered and DNS-managed in Cloudflare. Orange cloud (proxy) is enabled — all traffic flows through Cloudflare before reaching GitHub Pages.\n\n### Worker: `globalperspective-rss`\n\n**Routes:** `globalperspective.net/*` and `globalperspective.net/rss*`\n\n**Full source:** `WORKER_FULL_CODE.md`\n\nThe Worker handles these cases:\n\n| Path | Visitor type | What happens |\n|------|-------------|--------------|\n| `/rss` | Anyone | Proxied to `newsSensitiveData ?action=rss`, returns RSS 2.0 XML, 30 min edge cache |\n| `/weekly/country/:name` | Bot (24 patterns) | POSTs `country_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/weekly/thread/:id` | Bot (24 patterns) | POSTs `thread_preview` to Lambda, returns pre-rendered HTML with OG tags |\n| `/daily` and `/daily/:dateKey` | Bot (24 patterns) | POSTs `daily_brief` to Lambda (with 7-day fallback lookback), returns pre-rendered HTML with OG tags |\n| Everything else | Anyone | Passed through to GitHub Pages unchanged |\n\n**Bot patterns detected:** Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Googlebot, Bingbot, and 13 others (24 total in `BOT_PATTERNS`).\n\n**Why this matters:**\n- Social shares of country/thread pages show rich previews (real headline, description, image) instead of blank cards\n- AI crawlers (ChatGPT, Perplexity, Claude, Google) can read and cite page content — previously invisible due to React SPA empty shell\n- Human visitors always get the full React app unchanged\n\n**Key implementation detail:** The preview actions (`country_preview`, `thread_preview`, `daily_brief`) require POST with JSON body (`{ action, payload }`). The Lambda's `payload` field is only populated from POST body, not GET query params.\n\n---\n\n## Scheduling — EventBridge Rules + Scheduler (VERIFIED via AWS CLI 2026-05-26)\n\nMost schedules use **EventBridge Scheduler** (separate service from EventBridge Rules). Several use EventBridge Rules. All times UTC.\n\n### EventBridge Rules\n| Rule name | Schedule | Target |\n|-----------|----------|--------|\n| `TriggerDailyAnalysis` | `cron(30 6 * * ? *)` | newsThreadAnalysis (06:30 UTC daily) |\n| `TriggerNewsSystemsAnalysis` | `cron(15 7 * * ? *)` | newsSystemsAnalysis (07:15 UTC daily) |\n| `TriggerNewsEconomicImpact` | `cron(30 7 * * ? *)` | newsEconomicImpact (07:30 UTC daily) |\n| `TriggerNewsEconomicQuality` | `cron(0 8 * * ? *)` | newsEconomicQuality (08:00 UTC daily) |\n| `TriggerPredictionResolver` | `cron(0 9 * * ? *)` | newsPredictionResolver (09:00 UTC daily) |\n| `TriggerWeeklyBrief` | `cron(0 6 ? * SUN *)` | newsWeeklyBrief (Sundays 06:00 UTC — generates the weekly signals draft) |\n| `TriggerWeeklyMarkets` | `cron(30 8 ? * SUN *)` | newsWeeklyMarkets (Sundays 08:30 UTC — generates the weekly markets-report draft) |\n| `newsSourceAuditDaily` | `cron(30 8 ? * * *)` | newsSourceAudit (#24 — daily source-truth audit; SNS-alerts on summary drift) |\n| `MarketsDataHourly` | `rate(1 hour)` | newsMarketsData |\n| `MarketsYieldsDaily` | `cron(0 6 ? * MON-FRI *)` | newsMarketsData |\n| `MarketsMacrosWeekly` | `cron(0 2 ? * SUN *)` | newsMarketsData |\n\n### EventBridge Scheduler\n| Schedule name | Cron | Target |\n|---------------|------|--------|\n| `InvokeGoogleGemini` | `cron(0 */4 * * ? *)` | newsInvokeGemini-dev (every 4h) |\n| `InvokeNewsAgent` | `cron(5 */4 * * ? *)` | NewsProjectInvokeAgentLambda-dev (every 4h at :05) |\n| `countryIntelliegence` | `cron(0 7 * * ? *)` | newsCountryIntelligence (daily 07:00 UTC) |\n| `InvokeLinkedIn` | `cron(20 */3 * * ? *)` | newsPostLinkedin (every 3h at :20) |\n| `LinkedinThreadsDaily` | `cron(30 7/12 * * ? *)` | linkedInAutoPost (07:30 / 19:30 UTC) |\n| `InvokeDev` | `cron(0 23 * * ? *)` **TZ Asia/Tokyo** | newsPostDevTo (23:00 JST = **14:00 UTC** daily — scheduler is NOT in UTC) |\n| `Fact` | `cron(0 5 * * ? *)` | newsCountryFactsUpdater (05:00 UTC daily) |\n\n> The every-2h pipeline cadence and 3×/day country-intelligence schedule were both reduced on 2026-05-16 alongside the DeepSeek migration to control cost.\n\n### No schedule (manual only)\n- `newsPairIntelligence` — invoke manually or via ad-hoc AWS CLI call\n- `newsSensitiveData` / `newsSavedItems` — user-triggered via REST\n\n---\n\n## External APIs\n\n| API | Used by | Purpose |\n|-----|---------|---------|\n| DeepSeek V4 (`api.deepseek.com`) | newsInvokeGemini, NewsProjectInvokeAgentLambda, newsCountryIntelligence, newsSystemsAnalysis, newsEconomicImpact, newsPairIntelligence, newsPostDevTo | Topic clustering + AI content generation |\n| Gemini 2.5 Flash (OpenAI-compat endpoint) | newsThreadAnalysis, newsEconomicQuality, **newsWeeklyMarkets** (#25 — quality judge gating coverage notes) | Editorial analysis + LLM-as-judge (free tier, pacing) |\n| Brave Search (news + web) | newsInvokeGemini, newsThreadAnalysis, newsCountryIntelligence, newsPairIntelligence | News article search + grounding |\n| Frankfurter / FRED / **Yahoo Finance** / World Bank / CoinGecko | newsMarketsData | Free FX, bond yields, commodities + equities (Yahoo — replaced Stooq 2026-06-23), macros, crypto |\n| Wikidata (SPARQL) | newsCountryFactsUpdater | Head of state/government data |\n| ACLED API | newsCountryFactsUpdater | Active conflict data (approval pending) |\n| Mapbox Geocoding | newsSensitiveData | Location name → lat/lng |\n| Google Maps | WeeklyMap.jsx (embedded by CountryPage), WorldMap.jsx (legacy, unrouted) | Interactive map rendering. **Note:** WorldMapV2 (`/map`) uses **d3 + topojson**, not Google Maps |\n| Firebase Auth | AuthContext.jsx + newsSensitiveData + newsSavedItems | Passwordless sign-in + JWT verification |\n| Resend (`api.resend.com`) | newsBreakingAlert (#21, not deployed) | Transactional/alert email send. Chosen over SES 2026-06-10 (DX + no sandbox-approval wait). Key from `RESEND_API_KEY`. The recs/digest email also targets Resend. |\n| Perplexity `sonar` (`api.perplexity.ai`) | newsWeeklyMarkets (#25) | Self-searching LLM for the weekly-markets **web-context** tier (model searches + cites). Key `PERPLEXITY_API_KEY` — **unset today**, so the tier degrades to \"no clear driver found\" (honest). |\n\n> Paddle (billing + Customer Portal) was removed 2026-06-01 — the `newsStripeWebhook` Lambda and the billing proxy actions are gone. See [Lambda #12](#12-newsstripewebhook-name-was-legacy--handled-paddle--removed-2026-06-01).\n\n---\n\n## Frontend\n\n**Stack:** React 19 + Vite, React Router v6, GitHub Pages\n**Source:** `global-perspectives-starter/frontend/src/`\n**Production:** `docs/` (served by GitHub Pages)\n\n### Routes\n\nConstruction gate removed — all routes render real components in production. Auth routes show a preview/locked state for non-signed-in users with real public data visible for SEO.\n\nWired in `<Routes>` in `App.jsx` — 21 routes incl. catch-all (`/membership` added 2026-06-22; `/breaking` + `/breaking/:id` added 2026-06-26; `/spider-demo` **unlisted throwaway prototype** added 2026-06-27). Content is fully public; the only auth route is `/account`. `SignIn`/`AuthCallback` honor a `?returnTo=` param (added 2026-06-22) so post-auth lands back at checkout/origin instead of hard-coding `/weekly`.\n\n| Path | Component | Access |\n|------|-----------|--------|\n| `/` | `Home.jsx` | Public |\n| `/map` | `WorldMapV2.jsx` | Public |\n| `/about` | `AboutContact.jsx` | Public |\n| `/contact` | `Contact.jsx` | Public |\n| `/privacy` | `PrivacyTerms.jsx` | Public |\n| `/disclosures` | `Disclosures.jsx` | Public |\n| `/whitepaper` | `WhitepaperPage.jsx` | Public |\n| `/daily` | `DailyPage.jsx` | Public |\n| `/daily/:dateKey` | `DailyPage.jsx` | Public |\n| `/weekly-brief` | `WeeklyBriefPage.jsx` | Public (serif long-read of the latest published weekly brief) |\n| `/weekly-markets` | `WeeklyMarketsPage.jsx` | Public **redirect permalink** → `/economy?view=week` (preserves the `#instrument` hash). The weekly markets wrap was consolidated into `/economy`'s \"This week\" mode 2026-06-30; this URL is kept for inbound links / sharing / SEO. |\n| `/economy?view=week` | `EconomyPage.jsx` → `WeeklyMarketsView.jsx` | Public — the price-first weekly markets wrap (\"what moved this week & why\"; honest empty state until published), the \"This week\" mode of the Today/This-week toggle |\n| `/breaking` | `BreakingFeedPage.jsx` | Public (the breaking-alert feed — confirmed alerts grouped by day; honest empty state) |\n| `/breaking/:id` | `BreakingDetailPage.jsx` | Public (single breaking alert: What happened / How we got here / Our read / Market impact / Sources) |\n| `/economy` | `EconomyPage.jsx` | Public |\n| `/analyze` | `AnalysisStudio.jsx` | Public (BYOK self-serve analysis — see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)) |\n| `/track-record` | `TrackRecordPage.jsx` | Public |\n| `/weekly` | `WeeklyPage.jsx` | Public |\n| `/weekly/thread/:threadId` | `ThreadPage.jsx` | Public |\n| `/weekly/countries` | `CountryListPage.jsx` | Public |\n| `/weekly/country/:countryName` | `CountryPage.jsx` | Public |\n| `/signin` | `SignIn.jsx` | Public |\n| `/auth/callback` | `AuthCallback.jsx` | Public |\n| `/membership` | `MembershipPage.jsx` | Public (Polar checkout — $15/mo · $150/yr for Analysis-Studio-on-our-compute; reading stays free) |\n| `/account` | `Account.jsx` | Auth |\n| `/spider-demo` | `SpiderDemo.jsx` + `SpiderWorld.jsx` | Public — ⚠ **unlisted throwaway prototype**, the full 3-altitude analyst tool, **opens on World**: **World** tier (a global timeline: x = each country's peak-activity date, y = region lane, span bar = how long the situation ran, **connected by cross-country shared-actor links** — ambient actors excluded so regional clusters show, not a hairball; click-to-drill) ⇄ **Country** tier (timeline+lane causal web: x=time, y=category; solid shared-actor **backbone** + dashed causal toggle) ⇄ **Event** (\"✦ Analyze with AI\" → `dossier_analysis` grounded read). 12-country selector; not in nav; for design-partner discovery. Rebuilt d3-force→timeline 2026-06-30; backbone + selector + dossier consumer + World tier 2026-07-01. See `SPIDER_WEB_MODEL_PLAN.md` + `SPIDER_BUILD_SPEC.md` + `EVENT_DOSSIER_SPEC.md` + `ANALYST_TOOL_DIRECTION.md`. |\n| `*` | `NotFound` (inline in App.jsx) | Public catch-all (404) |\n\n**Removed/never-wired:** a \"Cut: orphans\" cleanup deleted several components and routes, and the 2026-05-26 subscription deprecation removed billing UI. The following are **no longer routed and the component files no longer exist**: `/cli` (CLIPage), `/intelligence-map` (IntelligenceMap), `/test/briefing-card` (BriefingCardTest), `/pricing` (Pricing), `/weekly/pairs` + `/weekly/pair/:slug` (PairPage/PairListPage), `/upgrade/success` (UpgradeSuccess — billing). Also deleted in the billing cleanup: `TrialBanner.jsx`, `WeeklyLockedPreview.jsx`, `useUserProfile.js`. `WorldMap.jsx` and `WeeklyMap.jsx` still exist as files but are **not routed** (`/map` uses WorldMapV2; there is no `/weekly-map` or `/map-v2` route).\n\n### Key Components\n\n~66 component files (in `src/components/` + 14 in `src/components/atoms/`), incl. `LedeBand.jsx` and the Analysis Studio pair `AnalysisStudio.jsx`/`ProviderModal.jsx` added 2026-06-10. Key ones:\n\n| Component | Purpose |\n|-----------|---------|\n| `Layout.jsx` | Nav shell with hamburger menu + a persistent \"?\" guided-tour trigger (`startTourForPath`); auto-runs the onboarding tour via `useAutoTour` (see [Onboarding](#onboarding-guided-tours)) |\n| `Home.jsx` | 3-col EditorialShell: a deterministic **\"Today's lede\" band** (`LedeBand`) directly under the StatusStrip, then region-grouped daily topics with per-topic AI toolbar (Summarize/Predict/Trace Cause) + per-topic economic-disruption badge (\"Economic impact →\" when a thread has a disruption); `TodayArchiveSidebar` + `TopicNav` rails |\n| `WorldMapV2.jsx` | The live map at `/map` — the **\"Today's lede\" band** (`LedeBand`) between the map title and the search box, then stacked layer lenses, arc overlays, \"Today's pulse\" |\n| `LedeBand.jsx` (atom) | Deterministic one-line \"Today's lede\" orientation strip on Home + Map. Fed by `utils/composeTopicsLede` (pure, no LLM — picks the day's story by disruption severity → urgency → trending → source count; counts trace to real inputs; headline is a verbatim topic title). Renders **nothing** when there is no real lede (honest empty state). Headline links to the story-arc thread page **only when the topic carries a real `threadId`** — no fallback link. Honesty eval: `quality/briefing/verify_lede.mjs`. SHIPPED 2026-06-10. |\n| `WorldMap.jsx` | Legacy Google Maps view — file kept, no longer routed |\n| `EconomyPage.jsx` | `/economy` — the markets home, with a **Today / This week** segmented toggle in the masthead (URL-synced `?view=week`, folded into `buildParams`/parse so it's shareable + refresh-safe + survives a filter/sort rewrite; default Today). **\"This week\"** renders `WeeklyMarketsView` (the consolidated weekly markets wrap, own centered editorial layout — see route table) and hides the live timestamp; **\"Today\"** renders the dashboard below. The Today mode: **Leads with a deterministic \"Today in the economy\" briefing band** (`.ep-briefing-band`, above the 3-col shell) — a one-paragraph synthesis composed by `utils/composeEconomyBriefing.js` from the same data already loaded (story count + severity split + most-cited cluster + sharpest story link + sanitized realized moves with consensus-vs-realized divergence flagged); no LLM, honesty-checked by `quality/briefing/assertions.js` (run `node quality/briefing/verify_compose.mjs`). **Rebuilt 2026-05-27 to match the editorial mockup** (own masthead band + 3-col shell, no longer `EditorialShell`; full-bleed via a `:has(.ep-page)` container escape, sticky rails offset by `--nav-h + --strip-h`). **Two-layer model** (see `ECONOMIC_INSTRUMENT_UNIVERSE_PLAN.md`): the right-rail **Market Context** is a *standing dashboard* — live levels for the full universe (Equities / Sectors / Commodities / Ags&Materials / Risk / Rates / Crypto via `useMarketsGlobal`), shown always, AI-independent. The center **leaderboard** (\"Repricing today\") is the *news-cited subset* (`useTopMovers` — consensus + direction-split + live level per instrument; **expand** → price sparkline (`useMarketsHistory`) + Key-levels box + a **lean** driving-stories list [Severity · Story → thread Economy tab · Direction] + affected-country chips; mechanism + historical analog are demoted to each story's thread Economy tab), then a dormant-instruments drawer + a severity-grouped by-story \"Active disruptions\" bridge. Left rail facets (severity / horizon / country). All real data — **honest degradation** where data is absent (no fabricated % change, severity bars, ISO codes, or analog %). **Deterministic display gate** (`utils/disruptionGate.js`): FX rows are relabelled to the foreign currency with direction derived from the rationale (the `USD/XXX` label + stored direction follow no consistent quoting convention), arrow suppressed when undetectable; historical-analog realized moves render only when the event resolves against the curated catalog (`findAnalogEvent`). Proven by `scripts/test-disruption-gate.mjs`. |\n| `MapSidePanel.jsx` | Per-country topic cards with AI toolbar |\n| `WeeklyPage.jsx` | 3-col EditorialShell: **tier-based front-page hierarchy** (`RISK_TIERS_PLAN.md` P3, 2026-07-03) above the category river — **1 LEAD** (tier `high` AND events ≤24h; Techmeme-style text-forward card: rule label, serif headline, why-it-matters, evidence row, arc dots, `↳ What changed` drift-delta line from the living-analysis loop) + **≤3 DEVELOPING** (fresh drift note OR ≥elevated & ≤48h; half-width rows; near-duplicate stories de-duped via Jaccard title similarity). Below the hierarchy the river is **time-banded with density decay** (2026-07-03): **This week** (last activity ≤7d) = full story cards · **Earlier this month** (8–30d) = condensed rows · **Older** (>30d) = collapsed count (collapses only when a fresher band has content, so the topmost populated band is always open). Threads bucket by `dateRange.to`. **Category is a filter-chip row** (All + per-category counts), not the grouping axis. Hierarchy renders **only on the default unfiltered view** (search/region/country/**category** filter = work mode = flat bands, hierarchy hidden); promoted threads are removed from the river **and** the right rail \"Rising This Week\" (no double-show); honest-empty if nothing qualifies. StatusStrip, left rail (search/period/sort/region/view-toggle); lazy-loaded `WeeklyMap` view mode |\n| `WeeklyMap.jsx` | Thread-colored markers, date playback, thread sidebar |\n| `ThreadPage.jsx` | 3-col EditorialShell thread deep-dive: StatusStrip, 4-stat row, and a **single** content tab system Timeline/Actors/Sources/**Economy** (economic disruption via `useEconomicImpact` + `MechanismCard`). The right \"Arc Intelligence\" rail is a **stacked, non-tabbed synthesis column** (Summary → What's Next → Trace → Watch → Live Web Evidence) — de-tabbed + de-duplicated 2026-06-29 (it previously had its own competing tab widget + duplicated key actors + a `DisruptionPreview` that now live only in the center tabs) |\n| `CountryListPage.jsx` | Grid index of all countries with intelligence |\n| `CountryPage.jsx` | Map-first country page: full-bleed map hero, then a 3-col EditorialShell — center tabs **Situation / Story Arcs / Causal Web / Coverage** (the `SystemsGraph` causal web was promoted out of the right rail into a full-width center **Causal Web** tab 2026-06-29; tab shows only when `systemsData.nodes` exist), right rail with risk/actors/watch/economic/macro/FX modules |\n| `DailyPage.jsx` | Daily Intelligence Brief display (`/daily`, `/daily/:dateKey`) + an `EconomicFootprint` section aggregating top instruments from `useDisruptionsList`. **Honest date (2026-06-10):** shows a relative pill (\"Yesterday\" / \"N days ago\") when the served brief is older than the requested date, and a fallback notice (\"Today's brief publishes at the end of the day — showing <date>\") when you open `/daily` before today's brief is generated (the daily brief is written once/day at 23:00 JST = **14:00 UTC** by `newsPostDevTo`, so until ~14:00 UTC `/daily` serves the prior day). |\n| `StoryEntryCard.jsx` | Entry card with Summarize/Predict/Trace Cause toggle |\n| `ThreadIntelligence.jsx` | Thread-level AI analysis display (storyArc, trajectory, etc.) |\n| `BriefingCard.jsx` | Formatted intelligence briefing card |\n| `BackgroundTimeline.jsx` | Historical timeline display for country/thread context |\n| `SaveButton.jsx` | Heart bookmark button — saves threads/countries/dailies to account |\n| `SignIn.jsx` | Firebase sign-in form — magic link + Google + **guest/anonymous** (`signInAsGuest`, \"Continue as guest\") |\n| `Account.jsx` | User account tabs: saved items + profile |\n| `IntelligenceLoader.jsx` | Animated loading states (typewriter + explode variants) |\n| `TrackRecordPage.jsx` | `/track-record` — forecast calibration scoreboard (stat cards, Brier score + verdict, calibration table, recently-resolved triggers); honest empty state until human-confirmed verdicts exist. See [Prediction calibration](#prediction-calibration-track-record) |\n| `AnalysisStudio.jsx` | `/analyze` — **BYOK self-serve analysis** (registered-only; see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)). Pick ≤4 stories → **Guided lens** / **Free-form** / **Deep research (web)** → cited deep-dive from cached `SUMMARY`/`PREDICTION`/`TRACE_CAUSE` (deep mode also web-searches). Output runs through the validator + source-robustness banner. Runs on the user's own key (browser-only). |\n| `ProviderModal.jsx` | The `/analyze` provider/model/key chooser modal (OpenAI · DeepSeek `v4-flash`/`-pro` · **Perplexity** · Gemini · OpenRouter · Anthropic; labels which can web-search). Writes `{provider,model,key}` to `localStorage` only — never sent to our servers. |\n| `MembershipPage.jsx` | `/membership` — Polar checkout ($15/mo · $150/yr = run Analysis Studio on our compute; reading stays free). Self-states availability; \"Sign in to subscribe\" passes `?returnTo=/membership`. Added 2026-06-22 (footer-linked, not in top nav). |\n| `SystemsGraph.jsx` | First-class causal-graph view (P2, 2026-06-22) — renders the **full** `systems_analysis` `{nodes,edges}` with `mechanism`/`lagDays`/`confidence`; each node links to its arc (`/weekly/thread/:id`), each edge shows its `citedEntries` count as evidence weight. Replaced the old `edges.slice(0,4)` inline cap. **Promoted out of CountryPage's right rail into a full-width center \"Causal Web\" tab 2026-06-29** (it was buried as the 5th of 8 stacked rail modules at rail width). Backend coverage `SYSTEMS_TEST_COUNTRIES` widened 2026-07-01 to 12 countries (Iran, Israel, US, Venezuela, China, Japan, Ukraine, Russia, France, Germany, DRC, South Africa). |\n| `BreakingFeedPage.jsx` | `/breaking` (2026-06-26) — the breaking-alert feed. Reuses `useNotifications`; groups confirmed alerts by Today/Yesterday/date; each card = BREAKING chip · category·regions · market pill · `SourceRobustness`. Honest empty state (\"Quiet is the normal state\"). |\n| `BreakingDetailPage.jsx` | `/breaking/:id` (2026-06-26) — native breaking-alert page via `useBreakingAlert`. Renders the structured story (What happened / How we got here [normalized TRACE_CAUSE] / Our read / Market impact / Sources); region chips → country/map; **story-arc link only when `hasArc`** (a real multi-entry thread). Falls back to the saved email text for legacy records. Honest not-found when the id isn't a confirmed alert. |\n| `atoms/BreakingStrip.jsx` | Slim pulsing \"BREAKING\" entry strip atop Home + Map (2026-06-26). Renders **only** when the newest confirmed alert is <24h old — else nothing (no stale/fabricated banner). Links to `/breaking/:id`. |\n| `atoms/SourceRobustness.jsx` | L1 source-robustness pill (P0, 2026-06-22) — amber \"⚠ Single-source\" vs green \"✓ Corroborated · N outlets · M regions\", from existing `outletCount`/`sourceCount`/`countries`. **Renders null on no data** (never a default \"corroborated\" badge). On Home topic-card meta + ThreadPage header kicker. The \"faithfulness ≠ truth\" principle made user-facing outside the BYOK Studio. |\n\n### Key Hooks\n\n25 total hooks (in `src/hooks/`):\n\n| Hook | Purpose |\n|------|---------|\n| `useGeminiTopics()` | Fetch daily topics; 1hr LocalStorage cache + 10min background poll |\n| `useWeeklyArchive()` | Fetch `archive_range` (30 days, fully public in early access); 30min cache |\n| `useThreadAnalyses(threadIds)` | Fetch thread-level AI analyses; 30min cache; no auth required |\n| `useCountryIntelligence(countryNames)` | Fetch country-level AI intelligence; 30min cache; no auth required |\n| `useCountryHistory(countryName)` | Fetch historical archive entries for a country |\n| `useSystemsAnalysis(countryName)` | Fetch causal-graph (nodes/edges) for a country |\n| `usePairAnalyses()` | Fetch all pair analyses list; 30min cache |\n| `useDailyBrief(dateKey)` | Fetch Daily Intelligence Brief for a date; falls back up to 7 days back and exposes `servedDateKey` (the date that actually returned data) so the UI can label \"Yesterday\"/\"N days ago\" honestly |\n| `useEconomicImpact(threadId)` | Fetch per-thread economic disruption analysis |\n| `useDisruptionsList()` | Fetch all economic-impact records (powers `/economy`, Home topic badges, DailyPage footprint, CountryPage, WorldMapV2, CountryListPage) |\n| `useMarketsHistory(symbol)` | Fetch per-instrument price history `[{date,value}]` for `/economy` sparklines; session-cached |\n| `useTopMovers()` | Fetch highest-magnitude economic-impact threads |\n| `useTrackRecord()` | Fetch forecast calibration scoreboard (`prediction_track_record`); 30min cache; powers `/track-record` |\n| `useMarketsGlobal()` | Fetch global FX/rates/commodities/equities/crypto snapshot |\n| `useMarketsCountry(countryName)` | Fetch country macro snapshot |\n| `useResearchBriefing(topicId)` | Fetch cached research briefing |\n| `useSavedItems(itemType)` | Manage user bookmarks via newsSavedItems Lambda (JWT required) |\n| `usePreferences()` | Read/write notification opt-ins via newsRecommend `get/set_prefs` (JWT); optimistic save, revert-on-error; powers Account → Notifications tab |\n| `useNotifications()` | Fetch the public breaking-alert feed (newsRecommend `list_alerts`, 5-min poll) + a localStorage read-marker for the unread badge; powers the nav `NotificationBell`, the `/breaking` feed, and the `BreakingStrip` |\n| `useBreakingAlert(id)` | Fetch a single breaking alert (newsRecommend `get_alert`); powers `/breaking/:id`. Null (→ honest not-found) when the id isn't a confirmed alert |\n| `useWeeklyBrief()` | Fetch the latest published weekly brief (`weekly_brief`); 30-min cache; powers `/weekly-brief` (rendered via the dependency-free `Markdown.jsx`) |\n| `useWeeklyMarkets()` | Fetch the latest published weekly markets report (`weekly_markets`); 30-min cache; powers `/economy`'s \"This week\" mode (`WeeklyMarketsView`). Null (→ honest empty state) until one is published |\n| `useSummary(topicId)` | Fetch AI summary for a topic |\n| `usePrediction(topicId)` | Fetch AI prediction for a topic |\n| `useTraceCause(topicId)` | Fetch trace_cause deep context for a topic |\n| `useTodayArchive()` | Fetch today's archive entries |\n| `useArticles(topicId)` | Fetch article sources for a topic |\n| `useCountrySignal(countryName)` | Country-level signal/metrics hook |\n| `useBookmarks()` | Bookmark state management |\n| `useIsMobile(breakpoint)` | Responsive breakpoint (default 600px) |\n\n### Onboarding (guided tours)\n\nA lightweight guided-tour system in `src/onboarding/`, built on **driver.js** (lazy-loaded on first tour run, so the ~25kb lib + CSS sit in their own chunk, not the main bundle):\n\n| File | Purpose |\n|------|---------|\n| `tours.js` | Tour definitions. `SITE_WELCOME` (a single screen-centered popover — no anchor — auto-shown on first visit) + `SITE_INTRO` (the fuller nav walk via `[data-tour=\"nav-*\"]`, **on-demand only**) + `PAGE_TOURS[path]` (per-page, anchored to that page's real controls). `pageTourForPath()` resolves a tour by route (prefix match for nested routes). Steps whose anchor is missing at drive time are dropped, so a tour never points at nothing. |\n| `useOnboarding.js` | Runner. `useAutoTour(pathname)` (called in `Layout`) auto-shows the `SITE_WELCOME` popover once ever, then each page tour once per page — gated by versioned `localStorage` keys (`gp_tour_v1_<id>`), never chaining two tours in one navigation, waiting (`waitFor`) for the anchor to exist before starting. `startTourForPath()` (the nav \"?\" button) always replays, ignoring the seen-flag, and falls back to the fuller `SITE_INTRO` walk on pages without their own page tour. |\n| `tour-theme.css` | Popover theme matching the design tokens (rust `--accent` Next button, `--card` surface). Higher specificity than driver's defaults via `.driver-popover.gp-tour`. |\n\nCurrently shipped: the `SITE_WELCOME` popover (auto), the `SITE_INTRO` walk (\"?\" on-demand), and the `/economy` page tour. Adding a page tour is data-only — a new entry in `PAGE_TOURS` + (optionally) `data-tour` anchors on the page.\n\n> Note: there is **no** `usePairIntelligence` hook — single-pair data is fetched via `restProxy.fetchPairAnalysis(slug)` directly.\n\n### Analysis Studio (BYOK self-serve analysis)\n\n`/analyze` (`AnalysisStudio.jsx`) — SHIPPED 2026-06-10 (commit `c1ef5f6`). The first \"analyze it yourself\" surface: a reader picks ≤4 real stories and gets a **cited deep-dive** built from our own intelligence. Full spec: `ANALYSIS_STUDIO_PLAN.md`. This is the feature **Polar membership monetizes** (`POLAR_BILLING_PLAN.md`): as of 2026-06-22 a **member runs it on our compute** via the `newsAnalyze` Lambda (our DeepSeek key, member-gated, `ANALYZE_DAILY_CAP=50`) instead of bringing their own — the BYOK path below stays free/public; see [[project-analysis-studio]], [[project-billing-deprecated]].\n\n- **Three input modes** (2026-06-11): **Guided** = 5 fixed lenses (Scenario forecast / Winners & losers / Economic ripple / Root-cause chain / Compare); **Free-form** = ask anything; **Deep research 🔎web** = the model does REAL web search (Perplexity sonar native, or Anthropic's `web_search_20250305` tool) seeded by the selected stories → What happened / Why / What next / Who's affected. Deep mode is **gated to search-capable providers** (disabled with a reason for DeepSeek/OpenAI/Gemini; `runChat` hard-refuses faking it). Pure prompt layer = `utils/analysisPrompt.js`; `utils/analysis.js` keeps the cited-context fetch + re-exports. Prompts upgraded to analyst-grade (Bottom-line thesis, structural drivers, scenario forks-on-distinct-outcomes + partition, economic direction→magnitude→mechanism) with a hard anti-fabrication counterweight.\n- **Verify system (every run) + source-truth (2026-06-11..15):** `utils/analysisValidator.js` checks the output for `phantom_citation` (error), `invented_figure`, `invented_date`, `no_citations`, `thin_input` → a pass/verify/flag banner. `utils/sourceRobustness.js` adds a **\"Source basis\"** line (single-source ⇒ \"unverified, corroborate\"). Quality has no ground truth → it's a **check, not a benchmark** (the auditor/scorers live offline in `quality/analysis/`: `run.mjs`, `judge.mjs`, `check.mjs`, `source_check.mjs`; the daily `newsSourceAudit` #24 is the automated guard). See `ANALYSIS_STUDIO_TESTING_PLAN.md` + `ANALYSIS_SOURCE_TRUTH_PLAN.md`.\n- **BYOK, key never leaves the browser.** `ProviderModal.jsx` chooses provider + model + key; `utils/byok.js` persists to `localStorage` only. `services/llm.js` calls the provider **directly from the browser** — one OpenAI-compatible path (OpenAI / DeepSeek / **Perplexity** / Gemini / OpenRouter) + an Anthropic adapter. DeepSeek shows explicit **V4 model versions** (`deepseek-v4-flash`/`-pro`; legacy `deepseek-chat` retires 2026-07-24) and sends `thinking:{type:'disabled'}` (V4 defaults to thinking, which broke output). **Registered-only:** `/analyze` requires a signed-in (non-anonymous) account — a blocking \"Sign in to analyze\" gate otherwise; key management (change/remove) lives on the Account \"Analysis key\" tab.\n- **Status:** DeepSeek browser-CORS verified for both prod origins; live BYOK call verified end-to-end. Deep-research (Perplexity/Anthropic) plumbing not yet live-tested (no key). The **member \"our-compute\" path is live** (`newsAnalyze`, `ANALYZE_DAILY_CAP=50`, deployed 2026-06-22) — BYOK remains uncapped/free. (Footer shows the deployed build version `v<sha>·<date>` for deploy confirmation.)\n\n### Service Layer\n\nTwo modules: `restProxy.js` (the actual transport) and `utils/contentService.js` (a thin wrapper over restProxy that adds normalization/sentence-trimming for topic content — **renamed 2026-05-26 from the misleading `graphqlService.js`; there is no GraphQL**). The topic AI hooks (`useGeminiTopics`, `useSummary`, `usePrediction`, `useTraceCause`, `useTodayArchive`) and `Home`/`MapSidePanel` call through `contentService`; everything else calls `restProxy` directly.\n\n### Design Tokens (colors)\n\n`src/tokens.js` (added 2026-06-24, P2a) is the **single source of truth for risk + category colors** — import from here, never redefine. It exports `RISK_COLORS` (pastel `{bg,color}` badge), `RISK_SOLID` (editorial hex, matches the `--risk-*` CSS vars), `RISK_RGB` (canvas arrays), `riskScoreToVar(score)` (score→`--risk-*`), `riskTierToVar(tier)` (tier→`--risk-*`), `CATEGORY_BADGE_COLORS` (`{bg,color}` chip), and `CATEGORY_DOT` (map-marker hex). It consolidated four divergent risk representations + three category maps previously copy-pasted across ~11 components.\n\n**Risk tier SEMANTICS live in `src/utils/riskTiers.js`** (added 2026-07-03, `RISK_TIERS_PLAN.md`) — the single source of truth for score→tier and level→tier, separate from `tokens.js` (which is paint). Exports `tierFromScore(score)`, `tierFromLevel(str)`, `TIER_ORDER`, `tierLabel(tier)` on the **canonical 25/50/75 bands** (low 0-24 · moderate 25-49 · elevated 50-74 · high 75-100, mirroring the `newsThreadAnalysis` prompt calibration). This collapsed **four** divergent band definitions (tokens 75/50 w/ no moderate; `RiskScoreBadge` 70/40; a moderate→elevated string alias; a `WeeklyPage`/CountryPage arc-card 50/25) onto one, and fixed the moderate tier (previously always rendered as orange \"elevated\"; now amber `--risk-m`). **Display rule: state = tier · change = audit numbers** — current-state displays (ThreadPage/CountryPage/WorldMapV2 header+stat+pill+strip) lead with the **tier word** + demote the raw score to fine print; change/audit contexts (the \"What changed\" band, correction chain, `RiskDeltaPill`, drift gate) stay numeric. (The risk **layout shells** — `EditorialShell` vs Economy's resizable `ep-shell` vs the Map's collapsible `mv2-body` — were intentionally **not** merged; they are behaviorally distinct, not density variants. See `PRODUCT_IMPROVEMENT_PLAN.md` P2.)\n\n> **BYOK exception (Analysis Studio):** `/analyze` does **not** route its LLM calls through `restProxy`. `services/llm.js` calls the user's chosen provider directly from the browser with the user's own key (see [Analysis Studio](#analysis-studio-byok-self-serve-analysis)); only the *story records* it analyzes come from `restProxy`'s public actions.\n\n```\nrestProxy.js\n configureProxy() / setAuthProvider(getIdToken) ← wired by AuthBridge on mount\n proxyAction(action, payload) ← no auth (public actions)\n └─ fetchTopicsCache()\n └─ fetchSummaryCache(topicId)\n └─ fetchPredictionCache(topicId)\n └─ fetchTraceCauseCache(topicId)\n └─ fetchResearchBriefingCache(topicId)\n └─ fetchTodayArchive()\n └─ fetchArchiveRange(days) ← public in early access\n └─ fetchNarrativeThread(threadId) ← public in early access\n └─ fetchThreadAnalyses(threadIds) ← public in early access\n └─ fetchCountryIntelligence(names) ← public in early access\n └─ fetchCountryHistory(countryName) ← public in early access\n └─ fetchSystemsAnalysis(countryName) ← public in early access\n └─ fetchDailyBrief(dateKey) ← public in early access\n └─ fetchPairAnalysis(slug)\n └─ fetchPairAnalysesList()\n └─ fetchEconomicImpact(threadId) ← economic_impact\n └─ fetchDisruptionsList() ← economic_impact_list\n └─ fetchTopMovers() ← economic_top_movers\n └─ fetchMarketsGlobal()\n └─ fetchMarketsCountry(countryName)\n └─ fetchMarketsHistory(key)\n └─ geocodeProxy(address)\n └─ fetchCountryPreview(countryName) ← SEO public preview\n └─ fetchThreadPreview(threadId) ← SEO public preview\n └─ fetchWeeklyBrief() ← weekly_brief (latest published)\n └─ fetchWeeklyMarkets() ← weekly_markets (latest published)\n\n proxyActionWithAuth(action, payload) ← attaches Bearer token if signed in; backend no longer requires it\n └─ (used internally by the archive/thread/country/daily helpers above; the billing\n helpers fetchUserProfile/fetchPortalSession were removed in the 2026-05-26 cleanup)\n\n savedItemsProxy (window.SAVED_ITEMS_ENDPOINT — separate Function URL)\n └─ saveItem(itemType, itemId, metadata)\n └─ unsaveItem(itemType, itemId)\n └─ fetchSavedItems(itemType)\n```\n\n### Caching Strategy\n\n| Data | LocalStorage key | TTL |\n|------|-----------------|-----|\n| Daily topics | `gemini_topics_cache_v2` | 1 hour |\n| Weekly archive | `gp_weekly_archive_v1` | 30 min |\n| Thread analyses | `gp_thread_analyses_v2` | 30 min |\n| Country intelligence | `gp_country_intel_v1` | 30 min |\n\n> The cache keys are plain (not user-scoped) since all content is public in early-access mode.\n\n### Narrative Threading\n\nStories are linked across days via `threadId`:\n1. `continues_topic` field → inherit parent's `threadId`\n2. Jaccard similarity (keywords + regions + category) against 7-day archive, threshold 0.4 → match existing thread\n3. Neither → generate new `thread-{slug}-{hash}`\n\n`threadId` is assigned by `NewsProjectInvokeAgentLambda` from the **raw** staging topics and stamped onto BOTH the served `latest` topics and the archive entries (fixed 2026-06-10 — see Lambda #2). Archive entries also carry `search_keywords` so the next day's Jaccard step has real keywords to match (both fields were previously dropped by `buildTopic()`, leaving `latest` link-less and Jaccard threading blind). The frontend relies on `topic.threadId` for the lede headline link and Home's \"Story arc →\" / \"Economic impact →\" badges, so this field must be present on `latest`.\n\nWeekly pages group topics by `threadId` to show how a story evolves across dates and geographies. `newsThreadAnalysis` runs daily to generate narrative-level AI analysis for top threads.\n\n---\n\n## Blog\n\nStatic HTML pages served from `docs/blog/` on GitHub Pages — no CMS, no build step.\n\n**URL:** `globalperspective.net/blog/`\n\n**Structure:**\n```\ndocs/\n blog/\n index.html ← blog index (list of all posts)\n thread-and-country-intelligence/\n index.html ← post: Thread + Country Intelligence launch\n```\n\n**To add a new post:**\n1. Create `docs/blog/<slug>/index.html` — copy the existing post as a template\n2. Add an entry to `docs/blog/index.html` (the `<ul class=\"post-list\">` section)\n3. Commit and push — no build needed (static HTML, not part of the React app)\n\n**Notes:**\n- Blog uses its own standalone HTML/CSS — separate from the React app\n- Logo image: `/logo_no_grey_bg.png` (served from `docs/` root)\n- Internal app links use full URLs (`https://globalperspective.net/...`), not React Router `<Link>`\n- Blog link is in the app footer (Layout.jsx) as a plain `<a href=\"/blog/\">`\n- Currently published via Claude — no self-serve CMS\n\n---\n\n## Deployment Workflow\n\n**Every frontend change requires a build + copy to `docs/`:**\n\n```bash\n# 1. Build\ncd global-perspectives-starter/frontend\nnpm run build\n\n# 2. Copy to production\nrm -rf ../../docs/assets\ncp -r dist/assets ../../docs/assets\ncp dist/index.html ../../docs/index.html\n\n# 2b. Resync the SPA fallback. docs/404.html MUST be byte-for-byte identical to\n# index.html — GitHub Pages serves it on every deep-link refresh (e.g.\n# refreshing /economy); a stale hash here = blank page on refresh. (npm run\n# build also auto-emits dist/404.html via the postbuild script.)\ncp ../../docs/index.html ../../docs/404.html\ndiff ../../docs/index.html ../../docs/404.html && echo \"404.html in sync\"\n\n# 3. NEVER overwrite docs/config.js (contains FIREBASE_CONFIG, SENSITIVE_PROXY_ENDPOINT, GOOGLE_MAPS_API_KEY)\n\n# 4. Update CHANGES.md, then commit\ncd ../..\ngit add docs/assets docs/index.html docs/404.html global-perspectives-starter/frontend/src/ CHANGES.md\ngit commit -m \"Description of changes\"\ngit push\n```\n\n**Backend (Lambda) changes:** Upload updated `index.js` as a deploy.zip via AWS Console, or `amplify push`. No build step needed for Lambda source edits.\n\n---\n\n## Key File Locations\n\n| Purpose | Path |\n|---------|------|\n| This architecture doc | `ARCHITECTURE.md` |\n| Claude instructions | `CLAUDE.md` |\n| Change log | `CHANGES.md` |\n| Lambda: news fetch + clustering | `amplify/backend/function/newsInvokeGemini/src/index.js` |\n| Lambda: AI generation per topic | `amplify/backend/function/NewsProjectInvokeAgentLambda/src/index.js` |\n| Lambda: thread-level analysis | `amplify/backend/function/newsThreadAnalysis/src/index.js` |\n| Lambda: country-level intelligence | `amplify/backend/function/newsCountryIntelligence/src/index.js` |\n| Lambda: causal graph analysis | `amplify/backend/function/newsSystemsAnalysis/src/index.js` |\n| Lambda: economic disruption | `amplify/backend/function/newsEconomicImpact/src/index.js` |\n| Lambda: economic quality judge | `amplify/backend/function/newsEconomicQuality/src/index.js` |\n| Lambda: markets data ingest | `amplify/backend/function/newsMarketsData/src/index.js` |\n| Lambda: save/bookmark | `amplify/backend/function/newsSavedItems/src/index.js` |\n| Lambda: REST proxy | `amplify/backend/function/newsSensitiveData/src/index.js` |\n| Lambda: social posting | `amplify/backend/function/newsPostLinkedIn/src/index.js` |\n| Lambda: Dev.to posting | `amplify/backend/function/newsPostDevTo/src/index.js` |\n| Frontend source | `global-perspectives-starter/frontend/src/` |\n| Production build | `docs/` |\n| Runtime config | `docs/config.js` (sets `window.FIREBASE_CONFIG`, `window.SENSITIVE_PROXY_ENDPOINT`, Google Maps key) |\n| Auth context | `global-perspectives-starter/frontend/src/contexts/AuthContext.jsx` |\n| REST proxy service | `global-perspectives-starter/frontend/src/services/restProxy.js` |\n\n---\n\n## Common Mistakes to Avoid\n\n1. **Pushing frontend source without building** — changes won't appear in production\n2. **Overwriting `docs/config.js`** — it sets the Firebase config and API Gateway endpoint at runtime\n3. **Inferring the AI provider from env var names** — `XAI_API_KEY` / `GROK_MODEL` / `GROK_API_URL` are legacy names that hold **DeepSeek** (or **Gemini**) values in production. Confirm with `aws lambda get-function-configuration`.\n4. **Reading old planning docs** — `HYBRID_NEWS_ARCHITECTURE.md`, `INTEGRATION_NOTES_Gemini_AppSync.md`, `NEWS_API_INTEGRATION_PLAN.md` are all outdated and should be ignored\n5. **Using `x-api-key` for auth** — gated endpoints now use `Authorization: Bearer <firebase-id-token>`, not static API keys\n6. **Assuming archive keys are `YYYY-MM-DD`** — the actual DynamoDB key format is `archive#YYYY-MM-DD`\n7. **Double CORS on Lambda Function URLs** — our billing/analyze Lambdas (`newsPolarBilling`, `newsAnalyze`, `newsSavedItems`, `newsRecommend`) set CORS **in code** (`corsHeaders` + an OPTIONS 204 handler). If the Function URL **also** has a CORS config, AWS *and* the code each add `Access-Control-Allow-Origin` → duplicate header → the browser rejects the response with \"**Failed to fetch**\" (server-side `curl` doesn't enforce CORS, so it passes tests and only breaks in a real browser). Keep the Function-URL CORS **empty** for any function whose code owns CORS. (Bit us at the Polar checkout 2026-06-30.)\n"}}}} |