Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/benben05059997_globalperspective-v1.json

1 line
4.7 MiB
Plaintext
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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 &lt;date&gt;\") 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"}}}}