diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..c8c6c33
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,20 @@
+# Secrets and local configuration
+.env*
+!.env.example
+
+# Dependencies and generated output
+node_modules
+.next
+out
+build
+coverage
+
+# Repository and agent tooling
+.git
+.github
+.worktrees
+.codegraph
+
+# Local artifacts
+tmp
+*.log
diff --git a/.env.example b/.env.example
index 62072f4..3c237a2 100644
--- a/.env.example
+++ b/.env.example
@@ -17,22 +17,65 @@ JINA_API_KEY=
TINYFISH_API_KEY=
SCRAPER_TIMEOUT_MS=8000
SCRAPER_MAX_RESPONSE_BYTES=1048576
-SCRAPER_MAX_REDIRECTS=3
MAX_SCRAPE_PAGES_PER_RESEARCH=5
+# Crawl Policy & News Extraction
+CRAWL_USER_AGENT=PartnerIQBot
+CRAWL_MIN_DOMAIN_INTERVAL_MS=1000
+ROBOTS_CACHE_TTL_MS=86400000
+ROBOTS_FAIL_MODE=metadata_only
+NEWS_ARTICLE_EXTRACTION_ENABLED=true
+
+
# Registry
VIETQR_ENABLED=true
# Storage
STORAGE_PROVIDER=supabase # supabase | memory
SUPABASE_URL=https://xyz.supabase.co
-SUPABASE_ANON_KEY=...
+SUPABASE_SERVICE_ROLE_KEY=server-only-secret
+# Browser Supabase Auth (publishable values; safe to expose to the client bundle)
+NEXT_PUBLIC_SUPABASE_URL=https://xyz.supabase.co
+NEXT_PUBLIC_SUPABASE_ANON_KEY=public-anon-or-publishable-key
+
+# Required when Langfuse telemetry is enabled; never use a public/default value.
+LANGFUSE_SALT=server-only-telemetry-secret
+
+# Research gateway (Cloudflare Worker + signed Cloud Run origin)
+# Set real staging/production values in workers/research-gateway/wrangler.jsonc.
+RESEARCH_GATEWAY_URL=https://research-gateway.example.workers.dev
+ORIGIN_URL=https://partneriq-origin.example.run.app
+SUPABASE_JWT_ISSUER=https://xyz.supabase.co/auth/v1
+SUPABASE_JWT_AUDIENCE=authenticated
+SUPABASE_TENANT_CLAIM=tenant_id
+SUPABASE_MEMBERSHIP_RPC=resolve_research_tenant
+SUPABASE_QUOTA_RPC=reserve_research_quota
+GATEWAY_KEY_ID=current
+REPLAY_WINDOW_SECONDS=60
+# Worker secrets: configure per environment with Wrangler; leave empty here.
+SUPABASE_API_KEY=
+GATEWAY_SIGNING_KEY=
+# Node origin secrets: current plus optional previous key during rotation.
+GATEWAY_SIGNING_KEY_CURRENT=
+GATEWAY_SIGNING_KEY_PREVIOUS=
# Rate limit guards (optional overrides)
MAX_CONCURRENT_RESEARCH=1
+MAX_QUERIES_PER_RESEARCH=6
+MAX_CONCURRENT_SOURCE_NODES=3
+MAX_CONCURRENT_PROVIDER_CALLS=2
SOURCE_TIMEOUT_MS=60000
MAX_RESEARCH_PER_DAY=50
MAX_TOKENS_PER_DAY=500000
+# Observability — Langfuse Cloud
+LANGFUSE_ENABLED=false
+LANGFUSE_PUBLIC_KEY=pk-lf-...
+LANGFUSE_SECRET_KEY=sk-lf-...
+LANGFUSE_BASE_URL=https://cloud.langfuse.com
+LANGFUSE_TRACING_ENVIRONMENT=production
+LANGFUSE_LOG_LEVEL=WARN
+
# App
NODE_ENV=development
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b43b8af..3b64f2e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,6 +11,7 @@ permissions:
jobs:
checks:
+ timeout-minutes: 20
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -25,6 +26,25 @@ jobs:
- name: Install dependencies
run: npm ci
+ - name: Detect research gateway Worker
+ id: worker
+ shell: bash
+ run: |
+ if [[ -f workers/research-gateway/package.json ]]; then
+ echo "enabled=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "enabled=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Install Worker dependencies
+ if: steps.worker.outputs.enabled == 'true'
+ working-directory: workers/research-gateway
+ run: npm ci --omit=optional
+
+ - name: Check Worker
+ if: steps.worker.outputs.enabled == 'true'
+ run: npm run worker:check
+
- name: Type check
run: npm run typecheck
@@ -34,5 +54,16 @@ jobs:
- name: Test
run: npm test
+ - name: Database integration tests
+ run: |
+ npx supabase start
+ eval "$(npx supabase status -o env)"
+ SUPABASE_TEST_URL="$API_URL" SUPABASE_TEST_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" SUPABASE_TEST_ANON_KEY="$ANON_KEY" npm run test:db
+
+ - name: Stop Supabase
+ if: always()
+ run: npx supabase stop --no-backup
+
- name: Build
run: npm run build
+
diff --git a/.github/workflows/deploy-worker-production.yml b/.github/workflows/deploy-worker-production.yml
new file mode 100644
index 0000000..07cf214
--- /dev/null
+++ b/.github/workflows/deploy-worker-production.yml
@@ -0,0 +1,59 @@
+name: Deploy research gateway (production)
+
+on:
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: Tested commit SHA to deploy
+ required: true
+
+permissions:
+ contents: read
+
+concurrency:
+ group: deploy-research-gateway-production
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ environment: production
+ timeout-minutes: 15
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout requested commit
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: ${{ inputs.ref }}
+
+ - name: Require an exact commit SHA
+ env:
+ DEPLOY_REF: ${{ inputs.ref }}
+ run: |
+ if [[ ! "$DEPLOY_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then
+ echo "Production deployments require a full 40-character commit SHA." >&2
+ exit 1
+ fi
+
+ - name: Set up Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: workers/research-gateway/package-lock.json
+
+ - name: Install Worker dependencies
+ working-directory: workers/research-gateway
+ run: npm ci --omit=optional
+
+ - name: Check Worker
+ run: npm run worker:check
+
+ - name: Validate deployment configuration
+ run: npm --prefix workers/research-gateway run predeploy
+
+ - name: Deploy production Worker
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ working-directory: workers/research-gateway
+ run: npm exec -- wrangler deploy --env production
diff --git a/.github/workflows/deploy-worker-staging.yml b/.github/workflows/deploy-worker-staging.yml
new file mode 100644
index 0000000..8dc8f73
--- /dev/null
+++ b/.github/workflows/deploy-worker-staging.yml
@@ -0,0 +1,51 @@
+name: Deploy research gateway (staging)
+
+on:
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: Git ref or commit SHA to deploy
+ required: true
+ default: main
+
+permissions:
+ contents: read
+
+concurrency:
+ group: deploy-research-gateway-staging
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ environment: staging
+ timeout-minutes: 15
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout requested ref
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: ${{ inputs.ref }}
+
+ - name: Set up Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: workers/research-gateway/package-lock.json
+
+ - name: Install Worker dependencies
+ working-directory: workers/research-gateway
+ run: npm ci --omit=optional
+
+ - name: Check Worker
+ run: npm run worker:check
+
+ - name: Validate deployment configuration
+ run: npm --prefix workers/research-gateway run predeploy
+
+ - name: Deploy staging Worker
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ working-directory: workers/research-gateway
+ run: npm exec -- wrangler deploy --env staging
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 327e52e..ac83c70 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -16,6 +16,7 @@ concurrency:
jobs:
publish:
+ timeout-minutes: 20
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push'
diff --git a/.gitignore b/.gitignore
index a811799..83272c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,3 +50,4 @@ next-env.d.ts
# temp fixtures
/tmp/
+supabase/.temp/
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 08c8605..11464f8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -200,7 +200,7 @@ Prefer single-file or single-test runs during iteration. Full suites are for the
When the user corrects your approach, append a one-line rule here before ending the session. Write it concretely ("Always use X for Y"), never abstractly ("be careful with Y"). If an existing line already covers the correction, tighten it instead of adding a new one. Remove lines when the underlying issue goes away (model upgrades, refactors, process changes).
-- (empty)
+- For external-content previews, separate source-quality signals from claim verification; default to metadata plus a short excerpt, respect paywalls and publisher controls, and treat jurisdiction-specific copyright review as a release requirement.
---
@@ -214,4 +214,4 @@ This boilerplate synthesizes:
- Community anti-sycophancy patterns (explicit banned phrases, direct-not-diplomatic).
- The AGENTS.md open standard (cross-tool portability via symlinks).
-Read once. Edit sections 10 and 11 for your project. Prune the rest over time. This file gets better the more you use it.
\ No newline at end of file
+Read once. Edit sections 10 and 11 for your project. Prune the rest over time. This file gets better the more you use it.
diff --git a/Dockerfile b/Dockerfile
index 1720aaf..9b0f815 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,7 +2,7 @@
# Dockerfile — PartnerIQ (Google Cloud Run / Production)
# ═══════════════════════════════════════════════════════
-FROM node:20-alpine AS base
+FROM node:22-alpine AS base
# Step 1: Install dependencies
FROM base AS deps
diff --git a/README.md b/README.md
index 1af92fd..502dcb0 100644
--- a/README.md
+++ b/README.md
@@ -1,523 +1,71 @@
# PartnerIQ (TechBridgeAI) 🚀
-> **AI-Powered Corporate Intelligence & Collaboration Intelligence Platform**
-> Nền tảng thẩm định doanh nghiệp thông minh tự động: Thu thập dữ liệu đa nguồn độc lập, tổng hợp hồ sơ chuẩn hóa qua LLM, đánh giá điểm phù hợp hợp tác (Collaboration Fit Score), theo dõi biến động lịch sử (Diff Engine) và xuất báo cáo One-Pager PDF chuyên nghiệp.
+> **AI-Powered Corporate Intelligence & Collaboration Fit Platform**
+> Nền tảng thẩm định doanh nghiệp thông minh: Tự động thu thập dữ liệu đa nguồn từ Internet, chuẩn hóa hồ sơ 360° qua LLM, chấm điểm tiềm năng hợp tác kinh doanh (Fit Score), nhận diện biến động theo thời gian và xuất báo cáo chuyên nghiệp.
[](https://github.com/devonxjz/TechBridgeAI/actions/workflows/ci.yml)
-[](https://vitest.dev/)
-[-black?logo=next.js)](https://nextjs.org/)
-[](https://www.typescriptlang.org/)
-[](https://openai.com/)
-[](https://supabase.com)
+[](https://vitest.dev/)
+[](https://nextjs.org/)
+[](https://workers.cloudflare.com/)
+[](https://openai.com/)
+[](https://supabase.com)
+[](https://langfuse.com/)
[](https://opensource.org/licenses/MIT)
---
-## 🖼️ Tổng Quan Kiến Trúc Hệ Thống (System Overview)
+## 🖼️ Kiến Trúc Hệ Thống (System Architecture)
+
+Dưới đây là mô hình kiến trúc tổng thể của hệ thống PartnerIQ, được điều phối bởi **Cloudflare Workers (Research Gateway)** nhằm tự động hóa quá trình thu thập và xử lý dữ liệu doanh nghiệp thời gian thực.
-
-
Hình 1: Kiến trúc tổng thể hệ sinh thái PartnerIQ (TechBridgeAI) — Tương tác đa nguồn, xử lý lõi AI, lưu trữ đa phiên bản và xuất bản tài liệu.
+
+
Kiến trúc tổng thể hệ sinh thái PartnerIQ — Quy trình thu thập đa nguồn, điều phối qua Cloudflare Workers, xử lý AI, lưu trữ Supabase và xuất bản báo cáo.
---
-## 🌟 Tính Năng Nổi Bật
-
-* 🌐 **Multi-source Research Pipeline (Thu thập đa nguồn thời gian thực):**
- * 🔍 **Web Search:** Tích hợp Serper Google Search API và chỉ tổng hợp dữ liệu trả về từ nguồn thật.
- * 🛡️ **Tiered Website Scraper (3 cấp độ tự phục hồi):** Chuỗi fallback `SafeDirect → Jina Reader → TinyFish` với cơ chế chống SSRF (Private IP/Localhost block), DNS Pinning, giới hạn luồng 1MB và bộ lọc HTML tuyến tính an toàn.
- * 🏛️ **VietQR Official Business Registry:** Tra cứu trực tiếp thông tin doanh nghiệp qua Mã số thuế (MST) với in-memory caching (7 ngày), tự động fallback sang Aggregator Search khi API nghẽn.
- * 📰 **Tin tức kinh doanh Việt Nam:** Tự động tìm kiếm các bài viết từ CafeF, Báo Đầu tư, VnExpress, Vietstock...
- * 💼 **Bóc tách LinkedIn / Nhân sự:** Thu thập thông tin ban lãnh đạo và đội ngũ cốt cán.
-* ⚡ **Real-time SSE Streaming:** Trực quan hóa tiến trình thu thập và phân tích dữ liệu dạng timeline sự kiện thời gian thực (Server-Sent Events).
-* 🧠 **OpenAI Structured Profile Builder:** Chuẩn hóa thông tin tự động bằng Zod Schema & Structured Outputs (Strict Mode), tính toán độ tin cậy (`overallConfidence`) theo trọng số từng nguồn.
-* 📊 **Analyst Module & Collaboration Fit Score (0–100):** Đánh giá mức độ phù hợp hợp tác kinh doanh theo 5 tiêu chí chuẩn hóa:
- * 🏢 **Phù hợp ngành (Industry Alignment - 30%)**
- * 👥 **Tương thích quy mô (Company Size Match - 20%)**
- * 📍 **Phù hợp địa lý (Geographic Relevance - 15%)**
- * 💻 **Trưởng thành số (Digital Maturity - 15%)**
- * 📈 **Hoạt động gần đây (Recent Activity - 20%)**
-* 🔍 **"What Changed?" Diff Engine:** So sánh tự động giữa các phiên bản hồ sơ của một doanh nghiệp (v1 → v2), nhận diện biến động về nhân sự, địa chỉ, ngành nghề và quy mô.
-* 🗄️ **Multi-Version Storage (Supabase PostgreSQL):** Lưu trữ lịch sử hồ sơ dạng JSONB, tối ưu hóa truy vấn và bảo toàn toàn bộ vết thay đổi.
-* 📑 **Bộ Công Cụ Xuất Bản Báo Cáo Chuyên Nghiệp:**
- * 📋 **Markdown & JSON Export:** Sao chép vào Clipboard hoặc tải file `.md` / `.json` ngay tức thì.
- * 📄 **Client-side PDF One-Pager (A4 Portrait):** Tạo báo cáo 1 trang tóm tắt chuẩn doanh nghiệp tiếng Việt có dấu với `@react-pdf/renderer` qua Dynamic Import (Zero Server Overhead, tải font Noto Sans cục bộ, hoạt động offline).
-
----
-
-## 🏛️ Lược Đồ Kiến Trúc & Class Diagram
-
-### 1. Kiến Trúc Phân Lớp (Hexagonal / Ports & Adapters Architecture)
-
-Hệ thống tuân thủ nghiêm ngặt nguyên lý **Ports & Adapters**, tách biệt hoàn toàn giữa logic nghiệp vụ lõi (Deep Core Modules) và các dịch vụ bên ngoài (Infrastructure Adapters):
-
-```mermaid
-graph TB
- subgraph Presentation ["1. Presentation Layer (Next.js App Router)"]
- UI["Web Dashboard & UI (React, TailwindCSS, Glassmorphism)"]
- API["API Route: /api/research (Thin Glue & SSE Streaming)"]
- end
-
- subgraph CoreModules ["2. Deep Core Modules (Domain Logic)"]
- RM["ResearchModule (Multi-source Orchestrator)"]
- PM["ProfileModule (LLM Structured Builder)"]
- DE["DiffEngine (Profile Comparison & Change Tracker)"]
- AM["AnalystModule (Collaboration Fit Score & Risk Engine)"]
- PDF["PDFExportEngine (Client-side One-Pager Generator)"]
- end
-
- subgraph Ports ["3. Ports & Seams (Interfaces)"]
- PortLLM["LLMAdapter"]
- PortSearch["SearchAdapter"]
- PortScraper["ScraperAdapter"]
- PortRegistry["RegistryAdapter"]
- PortStorage["StorageAdapter"]
- end
-
- subgraph Adapters ["4. Infrastructure Adapters"]
- OpenAI["OpenAI (gpt-4o-mini)"]
- Serper["Google Search (Serper API)"]
- TieredScraper["Tiered Scraper (SafeDirect -> Jina -> TinyFish)"]
- VietQR["VietQR Business Registry API"]
- Supabase["Supabase PostgreSQL (JSONB) / Memory"]
- end
-
- UI <-->|SSE Events / JSON| API
- UI --> PDF
- API --> RM
- API --> PM
- API --> AM
-
- RM --> PortSearch
- RM --> PortScraper
- RM --> PortRegistry
- PM --> PortLLM
- PM --> DE
- AM --> PortLLM
- API --> PortStorage
-
- PortLLM --> OpenAI
- PortSearch --> Serper
- PortScraper --> TieredScraper
- PortRegistry --> VietQR
- PortStorage --> Supabase
-```
+## 💡 PartnerIQ Là Gì? (Dành Cho Người Mới Bắt Đầu)
----
+Khi bạn muốn hợp tác với một đối tác hoặc doanh nghiệp mới, bạn thường mất hàng giờ tìm kiếm thông tin trên Google, tra cứu mã số thuế, đọc tin tức và phân tích rủi ro. **PartnerIQ tự động hóa toàn bộ quy trình này chỉ trong 3 bước đơn giản:**
-### 2. Lược Đồ Class - Domain Entities & Models (Class Diagram 1)
-
-Lược đồ mô tả toàn bộ cấu trúc dữ liệu miền (Domain Models) được định kiểu chặt chẽ trong hệ thống:
-
-```mermaid
-classDiagram
- direction TB
-
- class CompanyInput {
- +string name
- +string website
- +string taxId
- +string linkedinUrl
- +string[] additionalKeywords
- }
-
- class RawFinding {
- +SourceName source
- +string url
- +string content
- +Date extractedAt
- +number confidence
- +Record metadata
- }
-
- class CompanyProfile {
- +string id
- +number version
- +Date createdAt
- +CompanyInput input
- +string officialName
- +string[] tradingNames
- +string taxId
- +string[] industry
- +string description
- +number foundedYear
- +Address headquarters
- +string website
- +Person[] keyPeople
- +string[] products
- +string[] markets
- +CompanySize companySize
- +RevenueRange revenue
- +Activity[] recentActivities
- +Date lastUpdated
- +SourceCitation[] sources
- +number overallConfidence
- +boolean lowConfidence
- }
-
- class Address {
- +string street
- +string city
- +string province
- +string country
- }
-
- class Person {
- +string name
- +string title
- +SourceName source
- +number confidence
- }
-
- class Activity {
- +Date date
- +string title
- +string summary
- +string url
- +SourceName source
- }
-
- class SourceCitation {
- +SourceName source
- +string url
- +Date accessedAt
- +string[] fieldsContributed
- }
-
- class ProfileDiff {
- +string companyId
- +number fromVersion
- +number toVersion
- +FieldChange[] changes
- +string summary
- }
-
- class FieldChange {
- +string field
- +unknown oldValue
- +unknown newValue
- +string changeType
- +string significance
- }
-
- class AnalysisReport {
- +string companyId
- +Date generatedAt
- +FitScore fitScore
- +RiskFlag[] riskFlags
- +SuggestedAction[] suggestedActions
- +string executiveSummary
- }
-
- class FitScore {
- +number score
- +string reasoning
- +FitCriterion[] criteria
- }
-
- class FitCriterion {
- +string name
- +number score
- +number weight
- +string reasoning
- }
-
- class RiskFlag {
- +string type
- +string description
- +string severity
- +SourceName source
- }
-
- class SuggestedAction {
- +string action
- +string priority
- +string reasoning
- }
-
- class PdfPayload {
- +string companyName
- +string taxId
- +string[] industries
- +string description
- +number fitScore
- +string fitReason
- +PdfCriterion[] criteria
- +string executiveSummary
- +string[] risks
- +string[] actions
- +SourceItem[] sources
- +string generatedAt
- }
-
- CompanyProfile *-- CompanyInput : contains
- CompanyProfile *-- Address : headquarters
- CompanyProfile o-- Person : keyPeople
- CompanyProfile o-- Activity : recentActivities
- CompanyProfile o-- SourceCitation : sources
- ProfileDiff o-- FieldChange : changes
- AnalysisReport *-- FitScore : contains
- FitScore o-- FitCriterion : criteria
- AnalysisReport o-- RiskFlag : riskFlags
- AnalysisReport o-- SuggestedAction : suggestedActions
- CompanyProfile ..> PdfPayload : maps to
- AnalysisReport ..> PdfPayload : maps to
-```
+1. **📥 Bước 1 — Nhập thông tin**: Nhập tên công ty, mã số thuế (MST) hoặc website doanh nghiệp.
+2. **🧠 Bước 2 — AI tự động thu thập & phân tích**: Quét 5 nguồn dữ liệu độc lập, lọc bằng chứng, kiểm tra cache Supabase và tổng hợp hồ sơ qua mô hình LLM.
+3. **📊 Bước 3 — Nhận báo cáo toàn diện**: Xem hồ sơ 360° có dẫn chứng nguồn gốc, điểm tiềm năng hợp tác (Fit Score 0–100) và xuất báo cáo định dạng chuyên nghiệp.
---
-### 3. Lược Đồ Class - Deep Modules & Infrastructure Ports/Adapters (Class Diagram 2)
-
-Lược đồ mô tả các Interface (Ports), các Deep Modules và các Concrete Adapters thực thi:
-
-```mermaid
-classDiagram
- direction TB
-
- %% Ports (Interfaces)
- class LLMAdapter {
- <>
- +complete(prompt: string, options?: LLMOptions) Promise~string~
- +completeStructured~T~(prompt: string, schema: ZodSchema~T~, options?: LLMOptions) Promise~T~
- +stream(prompt: string, options?: LLMOptions) AsyncGenerator~string~
- }
-
- class SearchAdapter {
- <>
- +search(query: string, options?: SearchOptions) Promise~SearchResult[]~
- }
-
- class ScraperAdapter {
- <>
- +extract(url: string) Promise~ScrapedContent~
- }
-
- class RegistryAdapter {
- <>
- +findByTaxId(taxId: string) Promise~RegistryRecord | null~
- }
-
- class StorageAdapter {
- <>
- +saveProfile(profile: CompanyProfile) Promise~void~
- +getProfile(companyId: string, version?: number) Promise~CompanyProfile | null~
- +getLatestProfile(companyId: string) Promise~CompanyProfile | null~
- +listProfiles() Promise~CompanyProfile[]~
- +saveDiff(diff: ProfileDiff) Promise~void~
- +getDiffs(companyId: string) Promise~ProfileDiff[]~
- }
-
- %% Deep Modules
- class ResearchModule {
- <>
- +research(input: CompanyInput) AsyncGenerator~ResearchEvent~
- }
-
- class ProfileModule {
- <>
- +buildProfile(findings: RawFinding[], input: CompanyInput, existingId?: string, existingVersion?: number) Promise~CompanyProfile~
- +diffProfiles(current: CompanyProfile, previous: CompanyProfile) ProfileDiff
- }
-
- class AnalystModule {
- <>
- +analyze(profile: CompanyProfile, context?: AnalysisContext) Promise~AnalysisReport~
- }
-
- %% Concrete Adapters
- class OpenAILLMAdapter {
- -OpenAI client
- +complete()
- +completeStructured()
- +stream()
- }
-
- class SerperSearchAdapter {
- -string apiKey
- +search()
- }
-
- class TieredScraperAdapter {
- -ScraperAdapter[] tiers
- +extract(url: string) Promise~ScrapedContent~
- }
-
- class DirectScraperAdapter {
- -UrlSafetyValidator validator
- -number timeoutMs
- -number maxBytes
- +extract(url: string) Promise~ScrapedContent~
- }
-
- class JinaScraperAdapter {
- -string apiKey
- +extract(url: string) Promise~ScrapedContent~
- }
-
- class TinyFishScraperAdapter {
- -string apiKey
- +extract(url: string) Promise~ScrapedContent~
- }
-
- class VietQrRegistryAdapter {
- -Map cache
- -number ttlMs
- +findByTaxId(taxId: string) Promise~RegistryRecord | null~
- }
-
- class SupabaseStorageAdapter {
- -SupabaseClient client
- +saveProfile()
- +getProfile()
- +getLatestProfile()
- +saveDiff()
- }
-
- class MemoryStorageAdapter {
- -Map profiles
- -Map diffs
- +saveProfile()
- +getProfile()
- }
-
- %% Relationships & Implementations
- LLMAdapter <|.. OpenAILLMAdapter : implements
- SearchAdapter <|.. SerperSearchAdapter : implements
-
- ScraperAdapter <|.. TieredScraperAdapter : implements
- ScraperAdapter <|.. DirectScraperAdapter : implements
- ScraperAdapter <|.. JinaScraperAdapter : implements
- ScraperAdapter <|.. TinyFishScraperAdapter : implements
- TieredScraperAdapter o-- ScraperAdapter : contains fallback tiers
-
- RegistryAdapter <|.. VietQrRegistryAdapter : implements
-
- StorageAdapter <|.. SupabaseStorageAdapter : implements
- StorageAdapter <|.. MemoryStorageAdapter : implements
-
- ResearchModule ..> SearchAdapter : uses
- ResearchModule ..> ScraperAdapter : uses
- ResearchModule ..> RegistryAdapter : uses
- ProfileModule ..> LLMAdapter : uses
- AnalystModule ..> LLMAdapter : uses
-```
+## 🌟 5 Nguồn Dữ Liệu Hoạt Động Như Thế Nào?
----
+Hệ thống Research Gateway (Cloudflare Worker) thực thi việc thu thập thông tin qua 5 luồng song song:
-### 4. Sequence Diagram - Luồng Xử Lý Dữ Liệu Thời Gian Thực (Data Flow & Streaming)
-
-```mermaid
-sequenceDiagram
- autonumber
- actor User as 👤 Người Dùng
- participant UI as 💻 Next.js Client
- participant API as ⚡ API Route (/api/research)
- participant RM as 🔍 ResearchModule
- participant Sources as 🌐 5 Data Sources
- participant PM as 🧠 ProfileModule (LLM)
- participant AM as 📊 AnalystModule (Fit Score)
- participant DB as 🗄️ Supabase Storage
- participant PDF as 📑 PDF Engine (Client)
-
- User->>UI: Nhập tên công ty / website / MST
- UI->>API: POST /api/research (SSE Request)
- API-->>UI: Event: research:start
-
- API->>RM: research(input)
- loop Duyệt qua 5 nguồn dữ liệu
- RM->>Sources: Tìm kiếm (Web, Scraper, VietQR, News, LinkedIn)
- Sources-->>RM: Trả về dữ liệu thô (RawFinding)
- RM-->>API: Yield: progress & finding
- API-->>UI: SSE: research:progress & finding
- end
- RM-->>API: Complete (all findings)
-
- API-->>UI: Event: profile:building
- API->>PM: buildProfile(findings, input)
- PM-->>API: CompanyProfile (Structured)
- API->>DB: getLatestProfile(companyId)
- DB-->>API: Previous Profile (nếu có)
- opt Có phiên bản trước
- API->>PM: diffProfiles(current, previous)
- PM-->>API: ProfileDiff
- API->>DB: saveDiff(diff)
- end
-
- API->>AM: analyze(profile, context)
- AM-->>API: AnalysisReport (FitScore 0-100, Risks, Actions)
-
- API->>DB: saveProfile(profile)
- API-->>UI: Event: profile:ready & analysis:ready & done
- UI-->>User: Hiển thị giao diện Dashboard & Fit Score
-
- opt Người dùng click Xuất PDF
- User->>UI: Bấm "Xuất PDF One-Pager"
- UI->>PDF: mapToPdfPayload & renderAsync()
- PDF-->>User: Tải xuống PartnerIQ_CompanyName_YYYY-MM-DD.pdf (A4)
- end
-```
+1. **🔍 Tìm kiếm web (`web_search`):** Sử dụng công cụ Search API để tìm kiếm các bài viết, hồ sơ doanh nghiệp mới nhất trên Internet.
+2. **🌐 Website công ty (`website`):** Trích xuất nội dung trang chủ và các trang giới thiệu (`/about`, `/products`), tự động phân tích qua các công cụ cào dữ liệu an toàn.
+3. **📰 Tin tức truyền thông (`news`):** Quét các trang báo chí tài chính để phát hiện sự kiện nổi bật và dấu hiệu rủi ro.
+4. **🏛️ Đăng ký kinh doanh (`registry`):** Tra cứu dữ liệu định danh pháp lý chính thức qua Mã số thuế.
+5. **💼 Mạng lưới nhân sự (`linkedin`):** Khám phá cấu trúc lãnh đạo, nhân sự cốt cán và quy mô đội ngũ.
---
-## ⚙️ Cấu Hình & Biến Môi Trường (Configuration & Resilience)
-
-### File `.env.local` mẫu
-
-```dotenv
-# ─── LLM Provider ───
-LLM_PROVIDER=openai
-OPENAI_API_KEY=sk-...
-
-# ─── Search Provider ───
-SEARCH_PROVIDER=serper
-SERPER_API_KEY=...
-
-# ─── Scraper Provider & Fallback Chain ───
-SCRAPER_PROVIDER=tiered # tiered | tinyfish
-SCRAPER_DIRECT_ENABLED=true # Tier 1: Direct HTTP scraper + SSRF Guard
-SCRAPER_JINA_ENABLED=true # Tier 2: Jina AI Reader
-SCRAPER_TINYFISH_ENABLED=true # Tier 3: TinyFish API
-JINA_API_KEY=
-TINYFISH_API_KEY=
-SCRAPER_TIMEOUT_MS=8000
-SCRAPER_MAX_RESPONSE_BYTES=1048576 # Giới hạn stream 1MB
-SCRAPER_MAX_REDIRECTS=3
-MAX_SCRAPE_PAGES_PER_RESEARCH=5
-
-# ─── Registry Provider (VietQR) ───
-VIETQR_ENABLED=true # Tra cứu MST chính thức với 7-day memory cache
-
-# ─── Storage Provider (supabase | memory) ───
-STORAGE_PROVIDER=supabase
-SUPABASE_URL=https://xyz.supabase.co
-SUPABASE_ANON_KEY=eyJ...
+## 📊 Tiêu Chí Đánh Giá Điểm Hợp Tác (Collaboration Fit Score 0–100)
-# ─── Resource & Rate Limit Guards ───
-MAX_CONCURRENT_RESEARCH=1
-SOURCE_TIMEOUT_MS=30000
-MAX_RESEARCH_PER_DAY=50
-MAX_TOKENS_PER_DAY=500000
-```
+Hệ thống chấm điểm doanh nghiệp dựa trên **5 tiêu chí chuẩn hóa**:
-### Cơ Chế Fallback & Tự Phục Hồi (Circuit Breakers)
+* 🏢 **Phù hợp ngành nghề (Industry Alignment - 30%):** Đánh giá sự tương đồng trong lĩnh vực hoạt động.
+* 👥 **Tương thích quy mô (Company Size Match - 20%):** Đánh giá năng lực tiếp nhận và quy mô nhân sự.
+* 📍 **Vị trí địa lý (Geographic Relevance - 15%):** Khả năng triển khai thuận lợi theo vùng miền.
+* 💻 **Mức độ số hóa (Digital Maturity - 15%):** Đánh giá mức độ ứng dụng công nghệ và hiện diện trực tuyến.
+* 📈 **Hoạt động gần đây (Recent Activity - 20%):** Các dự án mới, sự kiện mở rộng hoặc phát triển trong 6–12 tháng qua.
-| Tình huống sự cố | Cơ chế tự động xử lý | Trạng thái hệ thống |
-| :--- | :--- | :--- |
-| **Direct Scraper bị chặn / WAF** | Tự động chuyển tier sang **Jina Reader → TinyFish** | Không gián đoạn |
-| **Jina Reader 429 (Rate Limit)** | Bỏ qua Jina, fallback tức thì sang **TinyFish** | Không gián đoạn |
-| **Thiếu API Key Jina/TinyFish** | Tự động bypass tier thiếu key mà không gây lỗi runtime | Tự thích ứng |
-| **Target URL là Local IP / Private** | Chặn ngay tại `UrlSafetyValidator` (SSRF Protection) | An toàn tuyệt đối |
-| **VietQR API quá tải / lỗi mạng** | Fallback sang tra cứu qua **Aggregator & Google Search** | Bền bỉ |
-| **Supabase không khả dụng** | Fallback sang **In-Memory Storage** cho môi trường dev/test | Sẵn sàng chạy offline |
+> **Tính năng Bằng chứng thực tế (Real-world Evidence):** Mọi kết luận từ AI đều đi kèm link dẫn chứng gốc từ các nguồn thu thập!
---
-## 🛠️ Cài Đặt & Khởi Chạy Nhanh (Getting Started)
+## 🚀 Hướng Dẫn Cài Đặt & Chạy Nhanh (Quick Start)
-### 1. Yêu cầu môi trường
-* **Node.js**: Phiên bản `>= 18.17.0` (khuyến nghị Node 20 LTS hoặc 24).
-* **Trình quản lý gói**: `npm` hoặc `pnpm`.
+### 1. Yêu cầu hệ thống
+* **Node.js**: Phiên bản `>= 18.17.0` (khuyến nghị Node 20 LTS hoặc Node 24).
+* **NPM / PNPM**.
### 2. Cài đặt các gói phụ thuộc
```bash
@@ -531,18 +79,47 @@ Tạo file `.env.local` từ file mẫu:
```bash
cp .env.example .env.local
```
-*(Điền các API Key cần thiết như `OPENAI_API_KEY`, `SERPER_API_KEY`, `SUPABASE_URL`,...)*
-### 4. Khởi chạy máy chủ phát triển
+Điền các khóa API cơ bản:
+```dotenv
+# LLM Provider
+LLM_PROVIDER=openai
+OPENAI_API_KEY=sk-...
+
+# Search Provider
+SEARCH_PROVIDER=serper
+SERPER_API_KEY=...
+
+# Storage (Supabase hoặc Memory)
+STORAGE_PROVIDER=supabase
+SUPABASE_URL=https://your-project.supabase.co
+SUPABASE_ANON_KEY=eyJ...
+SUPABASE_SERVICE_ROLE_KEY=eyJ...
+
+# Langfuse Observability (Tùy chọn)
+LANGFUSE_ENABLED=true
+LANGFUSE_PUBLIC_KEY=pk-...
+LANGFUSE_SECRET_KEY=sk-...
+LANGFUSE_BASE_URL=https://cloud.langfuse.com
+```
+
+### 4. Khởi chạy ứng dụng
```bash
npm run dev
```
-Mở trình duyệt và truy cập [http://localhost:3000](http://localhost:3000).
+Truy cập [http://localhost:3000](http://localhost:3000) trên trình duyệt để sử dụng.
----
+### 5. Kiểm thử hệ thống
+```bash
+npm run test # Chạy toàn bộ 27 test suites với Vitest
+npm run lint # Kiểm tra chuẩn mã nguồn ESLint
+npm run typecheck # Kiểm tra kiểu TypeScript
+npm run build # Biên dịch production build với Turbopack
+```
+---
-## 📄 Bản Quyền & Giấy Phép (License)
+## 📄 Giấy Phép & Bản Quyền (License)
-Dự án được phân phối dưới giấy phép **[MIT License](LICENSE)**.
-Phát triển bởi đội ngũ **PartnerIQ / TechBridgeAI** tham dự **Google AI Hackathon 2026**.
+Dự án được phân phối dưới giấy phép **[MIT License](LICENSE)**.
+Phát triển bởi đội ngũ **PartnerIQ / TechBridgeAI**.
diff --git a/docs/deployment/research-gateway.md b/docs/deployment/research-gateway.md
new file mode 100644
index 0000000..d477161
--- /dev/null
+++ b/docs/deployment/research-gateway.md
@@ -0,0 +1,99 @@
+# Research gateway delivery runbook
+
+The Cloudflare Worker is the public research gateway. The existing `release.yml` workflow continues to publish the Node/Cloud Run origin image; the Worker deploy workflows are separate and manual.
+
+## Package contract
+
+`workers/research-gateway` owns its source, Wrangler configuration, generated bindings, tests, and lockfile. Root delivery automation expects these Worker scripts:
+
+| Script | Expected behavior |
+| --- | --- |
+| `test` | Run Worker runtime tests without network credentials. |
+| `typecheck` | Type-check Worker source. |
+| `types:check` | Run `wrangler types --check` against committed generated bindings. |
+| `deploy:dry-run` | Run a credential-free `wrangler deploy --dry-run`. |
+| `check:startup` | Measure Worker startup and enforce Wrangler's startup gate. |
+
+Deployment workflows invoke the Worker-local Wrangler binary with `npm exec --prefix workers/research-gateway -- wrangler deploy --env `.
+
+The Worker package should pin compatible versions of Wrangler, Workers types, Vitest, and `@cloudflare/vitest-pool-workers` in its own `package.json` and `package-lock.json`. Root commands delegate with `npm --prefix`; dependencies are not duplicated into the Next.js package.
+
+CI activates the Worker checks as soon as `workers/research-gateway/package.json` exists. A committed Worker package must therefore include its lockfile and all scripts above.
+
+## Environment contract
+
+Keep non-secret values in each Wrangler environment and replace all example origins before deployment:
+
+- `ORIGIN_URL`: staging or production Cloud Run origin URL.
+- `SUPABASE_URL`, `SUPABASE_JWT_ISSUER`, `SUPABASE_JWT_AUDIENCE`: environment-specific auth configuration.
+- Membership is resolved through `resolve_research_tenant`; the database function must derive membership from the verified user and never trust a client tenant header.
+- Quota is reserved through the atomic, idempotent `reserve_research_quota` RPC.
+- `GATEWAY_KEY_ID`: identifier included with the current signing key.
+- `REPLAY_WINDOW_SECONDS`: origin replay-window limit; keep it aligned with the origin verifier.
+- `MAX_BODY_BYTES`, `ORIGIN_TIMEOUT_MS`, `SUPABASE_TIMEOUT_MS`, `QUOTA_OPERATION`, and `QUOTA_COST`: environment-specific gateway limits and quota inputs.
+
+Store secret values with Wrangler or protected GitHub environment secrets, never in `.env.example`, Wrangler config, workflow inputs, or logs:
+
+- Worker: restricted `SUPABASE_API_KEY` and `GATEWAY_SIGNING_KEY`.
+- Origin: `GATEWAY_SIGNING_KEY_CURRENT` and, during rotation only, `GATEWAY_SIGNING_KEY_PREVIOUS`.
+- GitHub `staging` and `production` environments: `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`.
+
+Use a narrowly scoped Cloudflare API token. Add required reviewers to the GitHub `production` environment. Pull-request CI receives no Cloudflare or application secrets.
+
+## Pre-deployment
+
+1. Deploy the matching Node origin image and configure signature verification in the migration plan's dual-accept/observe mode.
+2. Verify the Worker environment uses the correct origin, Supabase project, tenant-membership strategy, quota RPC, and signing key ID.
+3. Synchronize the current signing key to the Worker and origin using their secret stores.
+4. Run:
+
+ ```bash
+ npm ci
+ npm ci --prefix workers/research-gateway
+ npm run worker:check
+ ```
+
+5. Confirm the target origin readiness endpoint succeeds before exposing Worker traffic.
+
+## Deploy
+
+Use **Deploy research gateway (staging)** first. It accepts a branch, tag, or commit in the `ref` input. After smoke testing that exact revision, use **Deploy research gateway (production)** with its full 40-character commit SHA. Both workflows use GitHub environment-scoped credentials and do not run for pull requests.
+
+The workflows deploy only the Worker and do not replace `.github/workflows/release.yml` or its Cloud Run image publication behavior.
+
+## Smoke test
+
+The script always verifies that an unauthenticated request is rejected. With a short-lived valid staging JWT, it also verifies an SSE content type, reads the first streamed chunk, and cancels the client stream. It deliberately sends forged `x-internal-*` headers so the gateway's header sanitization path is exercised.
+
+```bash
+RESEARCH_GATEWAY_URL=https://staging-gateway.example.workers.dev \
+ npm run smoke:gateway
+
+RESEARCH_GATEWAY_URL=https://staging-gateway.example.workers.dev \
+RESEARCH_GATEWAY_SMOKE_JWT='' \
+RESEARCH_GATEWAY_SMOKE_QUERY='OpenAI' \
+ npm run smoke:gateway
+```
+
+Do not put JWTs on the command line in shared terminals or CI logs. For repeatable staging validation, inject the JWT through a protected environment secret. The authenticated check consumes quota by design.
+
+The automated script does not force quota exhaustion, cross-tenant cache access, or origin failure because those checks mutate shared state or require infrastructure controls. Verify them manually in staging with dedicated tenants and test quotas:
+
+1. Repeat the same idempotency key through the gateway and confirm quota is charged once.
+2. Exhaust a dedicated tenant's quota; confirm `429` and no origin invocation.
+3. Attempt cache select/refresh using another tenant's identifiers; confirm no data is returned or mutated.
+4. Make the staging origin unavailable; confirm the gateway's documented failure status and no buffered SSE body.
+5. Check Worker and origin logs for the same request ID and confirm tokens, signing keys, and authorization headers are absent.
+
+## Rollback and key rotation
+
+Rollback the Worker with Cloudflare's version rollback, then rerun the smoke test. Do not roll back tenant-isolation migrations after new tenant-scoped data has been written.
+
+For signing-key rotation:
+
+1. Add the new key to the origin as current while retaining the old key as previous.
+2. Set the Worker current key and key ID to the new values.
+3. Deploy and smoke test staging, then production.
+4. Remove the previous key from the origin after the replay window and rollback window close.
+
+If rollback requires the prior Worker version, keep the previous origin key accepted until rollback is no longer possible.
diff --git a/docs/handoff/handoff.md b/docs/handoff/handoff.md
index 4fadfcc..9403848 100644
--- a/docs/handoff/handoff.md
+++ b/docs/handoff/handoff.md
@@ -1,109 +1,161 @@
# Project Handoff — PartnerIQ (TechBridgeAI)
> **Repository**: [devonxjz/TechBridgeAI](https://github.com/devonxjz/TechBridgeAI)
-> **Current Release**: [v0.0.1](https://github.com/devonxjz/TechBridgeAI/releases/tag/v0.0.1)
-> **Latest Git Commit**: `ebe4434` / Tag `v0.0.1`
-> **Status**: ✅ All 5 Sprints Completed, 100% Tested (39/39 tests passing), Fully Functional & Live.
+> **Current Version**: `0.0.2`
+> **Status**: ✅ **TASK-4: Evidence Provenance & In-App Source Preview Fully Implemented & Verified** (239/239 tests passing across 31 test suites, Next.js build clean, TypeScript typecheck clean).
+
+### Current Session Handoff
+
+- **Task 4 (Sprints 0–8)** has been completely implemented, tested, and verified.
+- **Sprint 0 (`feat(evidence): define provenance contracts`)**: Runtime types and Zod schemas for `VerificationStatus`, `PreviewMode`, `RobotsDecision`, `FetchMethod`, `PublicationMetadata`, `PreviewPolicy`, `SourceSignals`, `ClaimEvidence`, `SourceDomainPolicy`, `ProfileField`.
+- **Sprint 1 (`feat(news): extract publication metadata`)**: Serper News vertical integration (`/news` endpoint), `SafeDirectScraperAdapter` transient HTML support, publication metadata normalizer (`cheerio@1.2.0`, JSON-LD extraction, OpenGraph, Canonical & AMP URLs, paywall detection, snippet control enforcement).
+- **Sprint 2 (`feat(crawl): respect publisher fetch policy`)**: `CrawlPolicy` politeness engine (`robots-parser@3.0.1`, 24h origin cache, process-local domain throttling interval, abort signal propagation).
+- **Sprint 3 (`feat(evidence): normalize citations and count independent sources`)**: `prepareEvidence`, `toSourceCitations`, SHA-256 content fingerprint deduplication, `buildClaimEvidence` with independent publisher counting.
+- **Sprint 4 (`feat(profile): integrate field-level evidence and provenance citations`)**: `ProfileModule` field-level claim validation, `fieldsContributed` attribution on citations, fallback claim resolution.
+- **Sprint 5 (`feat(analyst): resolve claim evidence for fit criteria and risk flags`)**: `AnalystModule` claim evidence resolution across Collaboration Fit Score criteria, Risk Flags, and Suggested Actions.
+- **Sprint 6 (`feat(serialization): preserve rich provenance in cache and export payloads`)**: JSONB snapshot multi-version cache serialization, markdown & PDF export preservation.
+- **Sprint 7 (`feat(ui): add in-app source preview dialog and field provenance`)**: In-app `SourcePreviewDialog` modal dialog, `EvidenceBadge` status indicators, field provenance inspection, interactive citation preview.
+- **Sprint 8 (`docs(evidence): complete TASK-4 evidence provenance and in-app preview`)**: Full test suite green (239 tests in 31 suites), Next.js production build verified, release gates green.
---
-## 1. Project Overview & Context
-
-**PartnerIQ (TechBridgeAI)** is an AI-powered corporate intelligence and collaboration evaluation platform tailored for Vietnamese enterprises. It automates:
-1. **Multi-Source Autonomous Research**: Gathers data across 5 independent sources (*Web Search, Website Scraping, Business News, Ministry Registry/MST, Key People*).
-2. **AI Structured Profile Synthesis**: Builds standardized, traceable `CompanyProfile` documents using OpenAI Structured Outputs (`gpt-4o-mini`).
-3. **Collaboration Fit Scoring (AnalystModule)**: Evaluates partnership potential (0–100) across 5 weighted criteria (*Industry Alignment 30%, Recent Activity 20%, Size Match 20%, Geographic Relevance 15%, Digital Maturity 15%*) with risk flags and actionable next steps.
-4. **"What Changed?" Diff Engine**: Automatically detects changes across profile iterations and generates human-readable diff reports.
-5. **Supabase PostgreSQL Multi-Versioning**: Subcollection-style JSONB multi-version storage (`company_profiles`, `company_diffs`) with zero hosting cost.
+## 1. Project Overview & Architecture
+
+**PartnerIQ (TechBridgeAI)** is an AI-powered corporate intelligence and partnership assessment platform tailored for Vietnamese enterprises. It provides:
+1. **Multi-Source Parallel Autonomous Research**: Gathers corporate intelligence across 5 bounded parallel sources (*VietQR/MST Registry, Official Website, Business News via Serper News, Web Search, Key People/LinkedIn*).
+2. **Polite Crawling & Provenance Engine**: Respects `robots.txt` directives, per-domain throttle spacing, paywall and `nosnippet` policies, and content fingerprinting.
+3. **Deterministic Evidence Engine**: Sanitizes URLs, deduplicates findings, scores confidence, counts independent publisher domains, and deterministically sorts evidence.
+4. **AI Structured Profile Synthesis**: Builds typed, schema-validated `CompanyProfile` documents with field-level claim evidence using LangChain-backed LLM adapters (`gpt-4o-mini`).
+5. **Collaboration Fit Scoring (AnalystModule)**: Evaluates partnership potential (0–100) across 5 weighted criteria (*Industry Alignment 30%, Recent Activity 20%, Size Match 20%, Geographic Relevance 15%, Digital Maturity 15%*) with risk flags and actionable steps backed by claim evidence.
+6. **In-App Source Preview**: Inspects article excerpts, publisher metadata, paywall notices, and direct links without speculative Google fallbacks.
+7. **"What Changed?" Diff Engine**: Computes schema-level diffs across profile iterations.
+8. **Supabase PostgreSQL Multi-Versioning**: Persists versioned snapshots (`company_profiles`, `company_diffs`) using subcollection-style JSONB columns.
+9. **Langfuse Cloud Tracing & Privacy Minimization**: End-to-end tracing via OpenTelemetry (`@langfuse/otel`), LangChain callbacks (`@langfuse/langchain`), client-side PII masking, and deterministic quality scoring.
+
+```mermaid
+flowchart TD
+ START([POST /api/research]) --> FanOut{Parallel Fan-Out\nmaxConcurrency: 3}
+ FanOut --> WebSearch[source.web_search\nSerper API]
+ FanOut --> Website[source.website\nTiered Scraper]
+ FanOut --> News[source.news\nSerper News + CrawlPolicy]
+ FanOut --> Registry[source.registry\nVietQR MST API]
+ FanOut --> LinkedIn[source.linkedin\nProfile Search]
+
+ WebSearch --> FanIn[evidence.prepare\nURL Canonicalization & Dedup & Fingerprints]
+ Website --> FanIn
+ News --> FanIn
+ Registry --> FanIn
+ LinkedIn --> FanIn
+
+ FanIn --> LoadProfile[profile.load\nSupabase Storage]
+ LoadProfile --> BuildProfile[profile.build\nLLM Structured Output + Field Evidence]
+ BuildProfile --> PersistProfile[profile.persist\nSave v(n) to Supabase]
+ PersistProfile --> DiffProfile[profile.diff\nCompute Diff vs Existing]
+ DiffProfile --> Analyze[analyst.analyze\n5-factor Fit Score + Claim Evidence]
+ Analyze --> EndNode([SSE Stream End & Langfuse Flush])
+
+ subgraph Observability ["🔭 Langfuse Observability & Privacy Boundary"]
+ OTel[NodeSDK + LangfuseSpanProcessor]
+ Tracing[traceResearch: partneriq.research]
+ Masking[maskPartnerIqTelemetryData: Redact PII / Secrets / Raw text]
+ Scores[emitResearchScores: source_coverage, profile_confidence, schemas, outcome]
+ end
+```
---
## 2. Work Completed & Current Status
-| Sprint / Feature Area | Scope | Verification Status |
+| Component / Layer | Implementation Details | Verification Status |
| :--- | :--- | :---: |
-| **Sprint 1: Foundation** | Types, Zod schemas, 4 Ports (LLM, Search, Scraper, Storage), In-memory adapters, Resource Guards, SSE stream utilities. | ✅ Passed |
-| **Sprint 2: Core Pipeline** | 5-source `ResearchModule`, OpenAI `ProfileModule` with Structured Output (`zodResponseFormat`), pure `DiffEngine`, API route `/api/research`. | ✅ Passed |
-| **Sprint 3: UI & Experience** | Dark mode glassmorphism UI, real-time SSE progress tracker, `ProfileCard`, `useResearch` hook, reactive state. | ✅ Passed |
-| **Sprint 4: Fit Score & Storage** | `AnalystModule` (5-factor Fit Score), Markdown/JSON export, Supabase PostgreSQL storage adapter with JSONB multi-versioning. | ✅ Passed |
-| **Sprint 5: Production & Polish** | Multi-stage Dockerfile, CI GitHub Actions, Demo presentation script ([`docs/DEMO_SCRIPT.md`](../DEMO_SCRIPT.md)), Ponytail code review, GitHub Release `v0.0.1`. | ✅ Passed |
-| **Storage Migration** | Successfully migrated from Firestore to **Supabase PostgreSQL** (`@supabase/supabase-js`), removed `@google-cloud/firestore`, created SQL migrations ([`supabase/schema.sql`](../../supabase/schema.sql)). | ✅ Passed |
-| **Compatibility Shims** | Added WebSocket shim for Node.js < 22 runtimes in [`src/adapters/storage/supabase.ts`](../../src/adapters/storage/supabase.ts). | ✅ Passed |
+| **Evidence Provenance (TASK-4)** | Serper News, `CrawlPolicy`, `normalizePublication`, `buildClaimEvidence`, `SourcePreviewDialog`, `EvidenceBadge`. | ✅ 239/239 tests passing |
+| **LangGraph Workflow** | `src/modules/workflow/index.ts` StateGraph with 5 fan-out nodes, deterministic fan-in, custom SSE event dispatching. | ✅ Tested & verified |
+| **Budget & Guard Rails** | `src/modules/research/budget.ts` tracking LLM token limits, call counts, provider concurrency. | ✅ Tested & verified |
+| **Research Matrix & Evidence** | `src/modules/research/queries.ts` & `src/modules/research/evidence.ts` with domain policies & query allocation. | ✅ Tested & verified |
+| **Tiered Scraper Engine** | `SafeDirectScraperAdapter` -> `JinaReaderScraperAdapter` -> `TinyFishScraperAdapter` with SSRF protection. | ✅ Tested & verified |
+| **Registry Adapter** | `VietQrRegistryAdapter` for official Vietnamese tax code (MST) lookup. | ✅ Tested & verified |
+| **Langfuse Cloud Tracing** | `@langfuse/otel` (NodeSDK in `instrumentation.ts`), `@langfuse/langchain` (`CallbackHandler`), `@langfuse/tracing`, deterministic scoring. | ✅ Live trace tested |
+| **Privacy Minimization** | Client-side PII redactor (`maskPartnerIqTelemetry`) masking tokens, emails, phone numbers, raw source dumps. | ✅ Tested & verified |
+| **Storage & Multi-versioning** | `SupabaseStorageAdapter` with JSONB tables (`company_profiles`, `company_diffs`). | ✅ Tested & verified |
+| **UI & Real-Time SSE** | Dark mode glassmorphism UI with real-time SSE progress, profile cards, source preview dialog, PDF export. | ✅ Operational |
---
-## 3. Architecture & Key Files
-
-The project follows a strict **Hexagonal / Ports & Adapters Architecture**:
+## 3. Directory Layout & Key Files
```
src/
+├── adapters/ # Swappable Hexagonal Ports & Adapters
+│ ├── llm/ # OpenAIAdapter (LangChain ChatOpenAI with structured output)
+│ ├── registry/ # VietQrRegistryAdapter (Vietnamese MST/Registry API)
+│ ├── scraper/ # TieredScraperAdapter (Direct -> Jina -> TinyFish)
+│ ├── search/ # SerperSearchAdapter (Google Search & News)
+│ └── storage/ # SupabaseStorageAdapter & MemoryStorageAdapter
├── app/
-│ ├── api/research/route.ts # Thin SSE Orchestration Route
-│ ├── components/ # ResearchForm, ResearchProgress, ProfileCard
-│ ├── hooks/use-research.ts # Real-time SSE State & Dispatcher
-│ ├── globals.css # Dark Glassmorphism Design System
-│ └── page.tsx # Landing & 2-column Results Layout
+│ ├── api/research/route.ts # Thin SSE Orchestration Route with Langfuse Tracing
+│ ├── components/ # ResearchForm, ResearchProgress, ProfileCard, ExportButtons
+│ ├── hooks/use-research.ts # Real-time SSE state dispatcher
+│ ├── globals.css # Dark Glassmorphism CSS design system
+│ └── page.tsx # Main 2-column layout (Form + Real-time Results)
+├── config/index.ts # Adapter Factory (DI via environment variables) & ResourceGuards
+├── instrumentation.ts # Next.js Node.js runtime hook for Langfuse OpenTelemetry
+├── lib/
+│ ├── export.ts & export-pdf.tsx # Markdown, JSON & React-PDF Exporters
+│ ├── stream.ts # SSE Streaming utilities
+│ └── types.ts # Zod Schemas & Domain Interfaces
├── modules/
-│ ├── research/ # Multi-source orchestrator (5 sources + fallback)
-│ ├── profile/ # Profile builder (OpenAI JSON schema) + Diff engine
-│ └── analyst/ # 5-factor Fit Score calculator & risk detector
-├── adapters/ # Swappable Infrastructure Ports
-│ ├── llm/ # OpenAIAdapter (gpt-4o-mini)
-│ ├── search/ # SerperSearchAdapter
-│ ├── scraper/ # TinyFishScraperAdapter, tiered real scrapers
-│ └── storage/ # SupabaseStorageAdapter, MemoryStorageAdapter
-├── config/index.ts # Adapter Factory (DI via environment variables)
-└── lib/
- ├── types.ts # Core Domain Types & Zod Schemas
- ├── stream.ts # SSE Streaming Utilities
- └── export.ts # Markdown & JSON Exporters
+│ ├── analyst/ # 5-factor Fit Score calculator & risk detector
+│ ├── profile/ # Profile builder & Diff engine
+│ ├── research/ # Query matrix (`queries.ts`), evidence processor (`evidence.ts`), budget (`budget.ts`)
+│ └── workflow/ # LangGraph StateGraph workflow (`index.ts`, `state.ts`)
+└── observability/
+ └── langfuse.ts # Tracing wrapper, PII masking, deterministic scores & OTel SDK
```
---
-## 4. Environment & Database Configuration
+## 4. Environment & Provider Configuration
-- **Environment File**: `.env` (and synchronized `.env.local` for Next.js).
+- **Configuration Files**: `.env`, `.env.local`
- **Active Providers**:
- - `LLM_PROVIDER=openai` (OpenAI `gpt-4o-mini` with fallback to Gemini)
- - `SEARCH_PROVIDER=serper` (Live Google Search results via Serper)
- - `SCRAPER_PROVIDER=tinyfish` (TinyFish extraction with direct HTML fallback)
- - `STORAGE_PROVIDER=supabase` (Supabase PostgreSQL JSONB tables)
-- **Supabase Tables Created & Verified**:
- - `public.company_profiles` (Key: `id, version`, column: `data JSONB`)
- - `public.company_diffs` (Key: `id`, column: `data JSONB`)
- - SQL Schema: [`supabase/schema.sql`](../../supabase/schema.sql)
+ - `LLM_PROVIDER=openai` (using `gpt-4o-mini`)
+ - `SEARCH_PROVIDER=serper`
+ - `SCRAPER_PROVIDER=tiered` (`SCRAPER_DIRECT_ENABLED=true`, `SCRAPER_JINA_ENABLED=true`, `SCRAPER_TINYFISH_ENABLED=true`)
+ - `STORAGE_PROVIDER=supabase`
+ - `LANGFUSE_ENABLED=true` (`LANGFUSE_BASE_URL=https://us.cloud.langfuse.com`, `LANGFUSE_TRACING_ENVIRONMENT=development`)
+- **Secrets & Keys Policy**:
+ - All API keys (`OPENAI_API_KEY`, `SERPER_API_KEY`, `JINA_API_KEY`, `TINYFISH_API_KEY`, `SUPABASE_ANON_KEY`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`) are managed via `.env.local` and redacted in logs/telemetry.
---
## 5. Verification & Test Suite
-- **Vitest Suite**: **39/39 tests passed across 10 test files** (`npm test`).
- - Unit tests: Adapters, Analyst, Export, Diff, Sources, Supabase Storage, Types validation.
- - Integration tests: `ResearchModule`, `ProfileModule`.
- - E2E tests: Full research & streaming workflow.
-- **TypeScript**: **TypeScript 7.0.2** (`@typescript/native`) as primary compiler (`npm run typecheck`) with **0 type errors**; TypeScript 6 (`@typescript/typescript6`) provides compatibility compiler API for ESLint.
-- **Production Build**: `npm run build` generates clean static & dynamic Next.js bundles.
+- **Vitest Suite**: **155/155 tests passing across 23 test suites** (`npm test`):
+ - `tests/unit/`: LangGraph runtime, LangChain LLM, Langfuse observability, evidence preparation, query matrix, tiered scraper, security, registry, types validation, diff engine, export, storage.
+ - `tests/integration/`: Research workflow, scraper transport, profile module.
+ - `tests/e2e/`: Full SSE streaming pipeline.
+- **Type Checking**: TypeScript 7.0.2 / Next.js typegen passing with 0 errors (`npm run typecheck`).
+- **Live Query Verification**: Successfully executed live end-to-end query for *Công ty Cổ phần VNG* via `/api/research`, validating SSE event stream, profile synthesis, 5-factor fit score, and trace transmission to Langfuse Cloud.
---
## 6. Next Steps & Recommended Actions
-1. **Vercel Cloud Deployment**:
- - Link repository `devonxjz/TechBridgeAI` on [Vercel](https://vercel.com).
- - Add environment variables (`LLM_PROVIDER`, `OPENAI_API_KEY`, `STORAGE_PROVIDER`, `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SCRAPER_PROVIDER`, `TINYFISH_API_KEY`).
- - Trigger deployment to get public HTTPS URL.
-2. **Search Provider**:
- - Provide a Serper key in `SERPER_API_KEY`; live search is required for production research.
-3. **Live Demo & Presentation**:
- - Follow the 3–5 minute live presentation script in [`docs/DEMO_SCRIPT.md`](../DEMO_SCRIPT.md) with demo companies (*FPT Corporation, Tập đoàn Vingroup, MISA*).
+1. **Production Deployment**:
+ - Deploy to Vercel or Docker container (`Dockerfile` multi-stage build).
+ - Configure production environment variables and set `LANGFUSE_TRACING_ENVIRONMENT=production`.
+2. **Langfuse Cloud Monitoring & Dashboards**:
+ - Monitor `partneriq.research` traces in [Langfuse Cloud Dashboard](https://us.cloud.langfuse.com/).
+ - Set up evaluation dashboards for deterministic scores (`source_coverage`, `profile_confidence`, `research_success`).
+3. **Enterprise Extensions**:
+ - Add custom criteria weights per user industry in `AnalystModule`.
+ - Expand registry connectors for regional registries beyond Vietnam.
---
## 7. Suggested Skills for the Next Agent
-- **`code-review`**: For reviewing future pull requests or proposed modifications against project standards.
-- **`diagnosing-bugs`**: If debugging any third-party rate limits or external API timeouts during live events.
-- **`ponytail-review`**: To maintain extreme code simplicity and prevent over-engineering.
-- **`github-workflow`**: For managing GitHub issues, branches, and future release tags.
+- **`code-review`**: For reviewing future PRs or features against established standards.
+- **`gsap-core` / `high-end-visual-design`**: For enhancing frontend UI micro-animations and dashboard polish.
+- **`diagnosing-bugs`**: For diagnosing any external API rate limits or third-party scraper timeouts.
+- **`github-workflow`**: For managing GitHub issues, releases, and CI/CD pipelines.
diff --git a/docs/plan/ARCHITECTURE.md b/docs/plan/ARCHITECTURE.md
index 6665219..3e30556 100644
--- a/docs/plan/ARCHITECTURE.md
+++ b/docs/plan/ARCHITECTURE.md
@@ -82,34 +82,87 @@ interface AnalystModule {
}
```
-### Thin Orchestration Layer (API Route)
+### Thin Orchestration Layer (API Route & LangGraph)
-API route **chỉ là glue** — nối 3 modules theo pipeline, stream events về client. Không chứa business logic.
+API route **chỉ là adapter mỏng** (`runtime = "nodejs"`, `maxDuration = 300`) — khởi tạo `createResearchWorkflow(deps)` và stream sự kiện Server-Sent Events qua `stream(input, options)`.
```typescript
-// /api/research/[companyId]/route.ts — pseudocode
-async function POST(req) {
- const input = parseInput(req.body)
-
- // 1. Research → stream progress
- const findings = []
- for await (const event of researchModule.research(input)) {
- stream.write(event)
- if (event.type === "finding") findings.push(event.finding)
- }
+// /api/research/route.ts — pseudocode
+export const runtime = "nodejs";
+export const maxDuration = 300;
+
+export async function POST(req: NextRequest) {
+ const input = CompanyInputSchema.parse(await req.json());
+ const workflow = createResearchWorkflow(deps);
+ const { stream, writer } = createSSEStream();
+
+ const langfuseCallback = createLangfuseCallback({
+ researchRunId,
+ companyId: slugify(input.name),
+ requestedSources,
+ });
+
+ (async () => {
+ try {
+ for await (const event of workflow.stream(input, {
+ researchRunId,
+ signal: controller.signal,
+ callbacks: langfuseCallback ? [langfuseCallback] : undefined,
+ })) {
+ writer.write(event);
+ }
+ } finally {
+ await flushLangfuse();
+ writer.close();
+ }
+ })();
+
+ return new Response(stream, { headers: { "Content-Type": "text/event-stream" } });
+}
+```
- // 2. Build profile
- const profile = await profileModule.buildProfile(findings)
- const previous = await storage.getLatestProfile(input.companyId)
- const diff = previous ? profileModule.diffProfiles(profile, previous) : null
+### LangGraph Parallel StateGraph Architecture
- // 3. Analyze
- const report = await analystModule.analyze(profile, { previousProfile: previous })
+Đồ thị trạng thái (`StateGraph`) điều phối việc thu thập và phân tích dữ liệu một cách độc lập và song song:
- // 4. Persist + return
- await storage.saveProfile(profile)
- stream.write({ type: "result", profile, diff, report })
-}
+```
+ ┌───────────────┐
+ │ START │
+ └───────┬───────┘
+ ┌───────────────┼───────────────┬───────────────┬───────────────┐
+ ▼ ▼ ▼ ▼ ▼
+ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
+ │web_search │ │ website │ │ news │ │ registry │ │ linkedin │
+ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘
+ └───────────────┼───────────────┴───────────────┴───────────────┘
+ ▼
+ ┌──────────────────┐
+ │ prepare_evidence │ (Deterministic Canonicalization & Deduplication)
+ └─────────┬────────┘
+ ▼
+ ┌──────────────────┐
+ │load_exist_profile│
+ └─────────┬────────┘
+ ▼
+ ┌──────────────────┐
+ │ build_profile │ (LLM with Untrusted-Data Delimiters)
+ └─────────┬────────┘
+ ▼
+ ┌──────────────────┐
+ │ persist_profile │
+ └─────────┬────────┘
+ ▼
+ ┌──────────────────┐
+ │build_persist_diff│
+ └─────────┬────────┘
+ ▼
+ ┌──────────────────┐
+ │ analyze │ (Analyst Module)
+ └─────────┬────────┘
+ ▼
+ ┌───────────────┐
+ │ END │
+ └───────────────┘
```
---
diff --git a/docs/plan/CLOUDFLARE_MIGRATION.md b/docs/plan/CLOUDFLARE_MIGRATION.md
new file mode 100644
index 0000000..f2c8d05
--- /dev/null
+++ b/docs/plan/CLOUDFLARE_MIGRATION.md
@@ -0,0 +1,533 @@
+# Kế hoạch migration Cloudflare Workers theo hướng Gateway-first
+
+## 1. Kiến trúc mục tiêu
+
+Giữ **Next.js + research workflow + scraper** trên Node.js/Cloud Run. Cloudflare Worker chỉ làm:
+
+- Supabase Auth
+- Xác định tenant
+- Quota admission qua Supabase RPC
+- Ký internal request
+- Proxy SSE đến Node origin
+
+```text
+Browser
+ ├── UI → Next.js/Cloud Run
+ └── POST /api/research
+ ↓
+ Cloudflare Worker
+ 1. Verify Supabase JWT
+ 2. Resolve tenant
+ 3. Reserve quota bằng Supabase RPC
+ 4. Ký internal request
+ 5. Proxy SSE
+ ↓
+ Next.js Node origin
+ 6. Verify gateway signature
+ 7. Chạy research workflow
+ 8. Stream SSE về browser
+```
+
+`Dockerfile` vẫn được giữ làm artifact triển khai Node origin. Không dùng nó để deploy Worker.
+
+## 2. Tiêu chí hoàn thành
+
+1. Thiếu hoặc sai Supabase JWT trả `401`.
+2. `tenant_id` không bao giờ được tin từ body/header do client gửi.
+3. Quota được trừ atomically trước khi gọi origin.
+4. Hết quota trả `429`; Node origin không được gọi.
+5. Request retry không bị trừ quota hai lần.
+6. Node origin từ chối request trực tiếp không có gateway signature.
+7. SSE được stream xuyên qua Worker, không bị buffer toàn bộ.
+8. Tenant A không thể đọc, select, refresh hoặc ghi cache tenant B.
+9. Có staging/production, CI dry-run, smoke test và rollback.
+
+---
+
+## 3. Phase 0 — Baseline và contract tests
+
+Trước khi sửa:
+
+- Chạy:
+ - `npm test`
+ - `npm run typecheck`
+ - `npm run lint`
+ - `npm run build`
+- Ghi nhận các lỗi có sẵn.
+- Không reset các thay đổi đang tồn tại trong working tree.
+- Viết contract tests cho:
+ - API `/api/research`
+ - SSE event sequence
+ - Cache hit/miss/select/refresh/bypass
+ - Public errors
+ - Abort và client disconnect
+ - Admission/quota hiện có
+
+Định nghĩa internal contract giữa Worker và Node origin:
+
+```text
+x-internal-tenant-id
+x-internal-user-id
+x-internal-request-id
+x-internal-timestamp
+x-internal-signature
+```
+
+**Gate:** baseline rõ ràng và contract tests chạy được trước refactor.
+
+---
+
+## 4. Phase 1 — Tách research handler khỏi Next.js route
+
+Hiện tại `src/app/api/research/route.ts` chứa cả:
+
+- HTTP parsing
+- Cache resolution
+- Workflow orchestration
+- SSE lifecycle
+- Observability
+- Persistence
+
+Cần tách thành:
+
+```text
+Next.js route adapter
+ ↓
+Framework-neutral research handler
+ ↓
+Research services/workflow
+```
+
+Next.js route vẫn giữ:
+
+```ts
+export const runtime = "nodejs";
+```
+
+Không refactor scraper trong phase này.
+
+### TDD
+
+- Test handler bằng Web `Request`/`Response`.
+- Test route adapter giữ nguyên contract cũ.
+- So sánh SSE events trước và sau refactor.
+
+**Gate:** route Next.js trở thành adapter mỏng, toàn bộ contract cũ vẫn pass.
+
+---
+
+## 5. Phase 2 — Supabase Auth và tenant identity
+
+### Nguồn `tenant_id`
+
+Ưu tiên theo thứ tự:
+
+1. Custom JWT claim do backend quản lý.
+2. Nếu một user có nhiều tenant: JWT chứa user identity, Worker lookup membership.
+3. Không nhận `tenant_id` trực tiếp từ client như nguồn chân lý.
+
+Worker phải verify:
+
+- Chữ ký JWT qua Supabase JWKS
+- `iss`
+- `aud`
+- `exp`
+- User status
+- Tenant membership
+
+JWKS có thể cache theo TTL, nhưng verification phải fail closed.
+
+### Signed gateway context
+
+Sau khi verify, Worker tạo signed context:
+
+```text
+tenantId
+userId
+requestId
+timestamp
+HTTP method
+pathname
+body digest
+```
+
+Worker phải xóa mọi `x-internal-*` do browser gửi trước khi tạo header mới.
+
+Node origin verify:
+
+- HMAC signature
+- Timestamp/replay window
+- Method/path/body digest
+- Key ID khi hỗ trợ rotation
+
+### TDD
+
+- Token hết hạn
+- Sai issuer/audience
+- Sai signature
+- Thiếu tenant
+- User không thuộc tenant
+- Forged tenant header
+- Signature bị sửa
+- Signature quá cũ
+- Request body bị sửa sau khi ký
+
+**Gate:** research workflow không thể bắt đầu nếu identity chưa hợp lệ.
+
+---
+
+## 6. Phase 3 — Tenant-isolated cache
+
+Audit toàn bộ storage/cache API hiện tại và thêm `tenantId` vào mọi operation:
+
+```ts
+lookup(tenantId, input)
+select(tenantId, input, companyId)
+prepareRefresh(tenantId, input, companyId)
+resolveMiss(tenantId, input)
+persist(tenantId, identity, snapshot)
+```
+
+### Supabase schema
+
+Các bảng cache cần có `tenant_id` và index/constraint theo tenant, ví dụ về mặt logic:
+
+```sql
+unique (tenant_id, company_id, version)
+unique (tenant_id, normalized_tax_id)
+```
+
+Không được tạo tenant mặc định âm thầm cho dữ liệu cũ. Migration phải:
+
+- Backfill bằng mapping xác định được; hoặc
+- Đánh dấu dữ liệu legacy cần xử lý; hoặc
+- Từ chối migration nếu không xác định được tenant.
+
+### RLS
+
+Ưu tiên RLS nếu request sử dụng user JWT.
+
+Nếu Node origin dùng service-role key:
+
+- RLS có thể bị bypass.
+- Application-level tenant filter trở thành bắt buộc.
+- Supabase RPC phải yêu cầu tenant context đã ký/xác thực.
+- Integration test phải chứng minh không có cross-tenant access.
+
+### TDD
+
+- Hai tenant dùng cùng một tax ID vẫn có cache độc lập.
+- Tenant A không select company của tenant B.
+- Tenant A không refresh snapshot tenant B.
+- Persist đồng thời không ghi nhầm tenant.
+- RPC không trả dữ liệu nếu tenant không khớp.
+
+**Gate:** Supabase integration tests chứng minh không có cross-tenant read/write.
+
+---
+
+## 7. Phase 4 — Quota atomic qua Supabase RPC
+
+Tạo một RPC duy nhất, ví dụ:
+
+```text
+reserve_research_quota(
+ tenant_id,
+ user_id,
+ operation,
+ idempotency_key,
+ cost
+)
+```
+
+Kết quả:
+
+```json
+{
+ "allowed": true,
+ "reservation_id": "...",
+ "remaining": 17,
+ "reset_at": "..."
+}
+```
+
+### Yêu cầu
+
+- Transaction atomic.
+- Lock/counter update an toàn khi concurrent.
+- `idempotency_key` unique trong tenant.
+- Retry cùng request không trừ quota lần hai.
+- Quota backend lỗi thì trả `503`, không gọi origin.
+- Hết quota trả `429`.
+
+Nếu business rule yêu cầu hoàn quota khi workflow thất bại, bổ sung:
+
+```text
+release_research_quota(reservation_id)
+```
+
+Không tự động hoàn quota nếu chưa chốt business rule, vì client có thể cố ý ngắt stream sau khi công việc tốn phí đã chạy.
+
+### TDD
+
+- Burst concurrent
+- Duplicate request
+- Exhausted quota
+- RPC timeout/unavailable
+- Worker retry
+- Duplicate release
+- Không gọi origin khi admission thất bại
+
+**Gate:** DB concurrency tests pass và origin spy xác nhận không có call khi quota bị từ chối.
+
+---
+
+## 8. Phase 5 — Xây Worker gateway
+
+Tạo Worker entrypoint độc lập. Worker không được import dependency graph của Next.js Node.
+
+### Cấu hình
+
+Thêm `wrangler.jsonc`:
+
+- Compatibility date hiện hành
+- `staging` và `production`
+- Non-secret variables
+- Observability
+- Origin URL theo environment
+
+Secrets không commit:
+
+- Supabase configuration nhạy cảm nếu có
+- Internal gateway signing keys
+- Các key phục vụ server-to-server
+
+Sau khi cấu hình ổn định:
+
+```bash
+wrangler types
+```
+
+Không viết tay interface `Env`.
+
+### Request pipeline
+
+```text
+1. Validate route/method
+2. Validate content type và body size
+3. Verify Supabase token
+4. Resolve tenant
+5. Reserve quota
+6. Generate signed internal headers
+7. Fetch Node origin
+8. Return origin Response.body trực tiếp
+```
+
+### SSE
+
+Worker phải stream:
+
+```ts
+return new Response(originResponse.body, {
+ status: originResponse.status,
+ headers: filteredHeaders,
+});
+```
+
+Không dùng:
+
+```ts
+await originResponse.text();
+```
+
+Các yêu cầu khác:
+
+- Forward cancellation signal.
+- Không lưu request-scoped state trong module globals.
+- Không để floating promise.
+- Redact token và secrets khỏi logs.
+- Log JSON với `requestId`, outcome auth/quota/origin.
+- Không cache SSE response.
+
+### TDD
+
+- Auth success/failure
+- Quota success/failure
+- Header sanitization
+- Signed context
+- SSE chunk được nhận dần, không buffer
+- Client cancellation abort origin fetch
+- Origin timeout và 5xx
+- Supabase timeout
+
+**Gate:**
+
+```bash
+wrangler types --check
+npm test
+wrangler deploy --dry-run
+wrangler check startup
+```
+
+---
+
+## 9. Phase 6 — Khóa Node origin
+
+Node endpoint `/api/research` phải verify internal signature trước khi:
+
+- Resolve cache
+- Gọi Supabase
+- Khởi tạo LLM/search/scraper
+- Chạy workflow
+
+Direct public request không có chữ ký trả `401` hoặc `403`.
+
+### Key rotation
+
+Hỗ trợ ngắn hạn hai key:
+
+```text
+GATEWAY_SIGNING_KEY_CURRENT
+GATEWAY_SIGNING_KEY_PREVIOUS
+```
+
+Worker ký bằng current key. Origin chấp nhận current và previous trong cửa sổ rotation.
+
+### Network security
+
+Nếu Cloud Run cho phép:
+
+- Hạn chế ingress phù hợp.
+- Không coi network restriction là thay thế cho chữ ký.
+- Không để origin URL trở thành cơ chế bảo mật duy nhất.
+
+**Gate:** direct request vào Node research endpoint bị từ chối, request qua Worker hoạt động.
+
+---
+
+## 10. Phase 7 — CI/CD và rollout
+
+### Pull request CI
+
+Chạy:
+
+1. Node unit/integration tests
+2. Worker runtime tests
+3. Typecheck
+4. Lint
+5. Next.js build
+6. Generated Worker binding check
+7. Wrangler dry-run
+8. Supabase migration validation
+
+### Staging
+
+Worker staging trỏ tới:
+
+- Node staging origin
+- Supabase staging
+- Staging signing keys
+
+Smoke tests:
+
+- JWT hợp lệ/không hợp lệ
+- Quota allow/deny
+- Cache tenant isolation
+- SSE streaming
+- Client cancellation
+- Origin unavailable
+
+### Production rollout
+
+1. Deploy origin có signature verification ở chế độ dual-accept/observe.
+2. Deploy Worker nhưng chưa gắn production route.
+3. Test qua preview URL.
+4. Route traffic nội bộ/canary.
+5. Theo dõi:
+ - `401`, `403`, `429`, `5xx`
+ - Supabase RPC latency
+ - Origin handshake latency
+ - SSE completion/cancellation
+ - Quota duplicate rate
+6. Tăng traffic dần.
+7. Chuyển origin sang signed-only.
+
+### Rollback
+
+- Rollback Worker version hoặc route.
+- Tạm thời dùng cửa sổ dual-accept tại origin.
+- Không rollback migration tenant nếu hệ thống đã ghi dữ liệu theo schema mới.
+- Giữ khả năng disable quota enforcement bằng cấu hình chỉ trong rollout window, không giữ vĩnh viễn.
+
+---
+
+## 11. Phase 8 — Native Workers là project riêng
+
+Sau khi gateway ổn định mới đánh giá chuyển toàn bộ workload sang Workers.
+
+Các blocker phải xử lý:
+
+- Thay `node:http`, `node:https`, `node:dns` trong scraper.
+- Thiết kế lại SSRF/DNS rebinding protection cho Workers.
+- Kiểm tra OpenAI SDK hoặc chuyển sang REST bằng `fetch`.
+- Kiểm tra Supabase SDK bundle/runtime.
+- Thay OpenTelemetry Node và Langfuse lifecycle.
+- Đưa job dài sang Cloudflare Workflows/Queues nếu phù hợp.
+- Đánh giá OpenNext cho UI.
+- Benchmark CPU, memory, subrequests và streaming duration.
+
+`nodejs_compat` có thể hỗ trợ một số thư viện, nhưng không được xem là cách giữ nguyên toàn bộ kiến trúc Node hiện tại.
+
+---
+
+## 12. Thứ tự thay đổi dự kiến
+
+### Tạo mới
+
+- Worker entrypoint
+- `wrangler.jsonc`
+- Generated Worker environment types
+- Supabase JWT verification module
+- Internal signing module
+- Worker tests
+- Supabase quota migration/RPC
+
+### Sửa
+
+- `src/app/api/research/route.ts`
+- Storage/cache interfaces
+- `src/adapters/storage/supabase.ts`
+- Supabase schema và migrations
+- `.env.example`
+- CI/release workflows
+- Deployment documentation
+
+### Giữ lại
+
+- `Dockerfile` cho Node origin
+- Node-only scraper trong gateway-first phase
+- `runtime = "nodejs"` cho research route tại origin
+
+---
+
+## 13. Ước lượng
+
+| Nhóm công việc | Ước lượng |
+|---|---:|
+| Baseline và handler extraction | 1–2 ngày |
+| Supabase Auth và signed context | 1–2 ngày |
+| Tenant-isolated cache | 1–2 ngày |
+| Atomic quota RPC | 1–2 ngày |
+| Worker gateway và tests | 2–3 ngày |
+| Origin lockdown, CI/CD, staging | 1–3 ngày |
+| **Tổng gateway-first** | **7–14 ngày** |
+
+Native Workers là migration riêng, không nên gộp vào delivery này.
+
+## 14. Ba thông tin cần khóa trước implementation
+
+1. URL Node origin cho staging và production.
+2. `tenant_id` sẽ nằm trong Supabase JWT custom claim hay được resolve từ bảng membership.
+3. Quota có được hoàn khi workflow thất bại/client ngắt stream hay không.
+
+Khuyến nghị cho mục 3: **không hoàn quota sau khi origin đã bắt đầu tác vụ tính phí**; chỉ hoàn nếu lỗi xảy ra trước lần gọi provider đầu tiên.
\ No newline at end of file
diff --git a/docs/research/2026-08-28-news-source-trust.md b/docs/research/2026-08-28-news-source-trust.md
new file mode 100644
index 0000000..250b1e4
--- /dev/null
+++ b/docs/research/2026-08-28-news-source-trust.md
@@ -0,0 +1,153 @@
+# Nghiên cứu hiển thị nội dung báo và tín hiệu độ tin cậy nguồn cho TechBridgeAI
+
+**Ngày kiểm chứng:** 2026-08-28
+**Phạm vi:** chỉ dùng chuẩn web, RFC, tài liệu Google Search Central, Schema.org, C2PA và OWASP.
+**Trạng thái:** đề xuất sản phẩm/kỹ thuật, chưa sửa mã nguồn.
+
+## Kết luận ngắn
+
+Không nên dùng `iframe` làm cơ chế chính để hiển thị bài báo trong app. Về kỹ thuật, nhiều trang có thể chặn nhúng bằng `Content-Security-Policy: frame-ancestors` hoặc `X-Frame-Options`; kể cả khi nhúng được thì same-origin policy vẫn ngăn app đọc/điều khiển DOM của trang báo cross-origin. Frontend cũng không thể tự `fetch()` HTML của hầu hết báo chí nếu server bên kia không bật CORS. Vì vậy phương án mặc định nên là: tìm URL gốc, lấy metadata + excerpt ở server, render bản xem nhanh đã làm sạch trong app, và luôn giữ nút mở bài gốc. `iframe` chỉ nên là fallback preview khi trang cho phép nhúng. ([HTML Standard](https://html.spec.whatwg.org/multipage/iframe-embed-object.html), [MDN: same-origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Same-origin_policy), [MDN: CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS), [MDN: CSP frame-ancestors](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors), [RFC 7034](https://www.rfc-editor.org/info/rfc7034/))
+
+UI cũng không nên tuyên bố nhị phân kiểu “đúng/sai” hay “đã xác minh”. Nên hiển thị “nguồn gốc” và “tín hiệu xuất bản” để người dùng tự đánh giá: publisher, author, ngày xuất bản/chỉnh sửa, canonical URL, structured data, chính sách biên tập nếu có, và mức đồng thuận liên nguồn. Đây là các tín hiệu provenance có chuẩn máy đọc sẵn trên web, nhưng không đủ để kết luận chân lý. ([Google Article structured data](https://developers.google.com/search/docs/appearance/structured-data/article), [Schema.org `author`](https://schema.org/author), [Schema.org `publisher`](https://schema.org/publisher), [Schema.org `reviewedBy`](https://schema.org/reviewedBy), [Schema.org `publishingPrinciples`](https://schema.org/publishingPrinciples), [Schema.org `sameAs`](https://schema.org/sameAs), [Schema.org `mainEntityOfPage`](https://schema.org/mainEntityOfPage))
+
+## 1. Hiển thị nội dung bài báo trong app
+
+### Khuyến nghị
+
+Mặc định:
+
+1. Lưu `original_url`.
+2. Chuẩn hóa `canonical_url` nếu có.
+3. Ở server, lấy metadata và excerpt ngắn.
+4. Render text/plain hoặc HTML đã sanitize trong app.
+5. Giữ CTA `Xem bài gốc`.
+
+Chỉ dùng `iframe` khi mục tiêu là “xem nguyên trang từ publisher” và trang đó thực sự cho nhúng. Nếu dùng `iframe`, đặt `sandbox` chặt, thêm `referrerpolicy`, và chấp nhận rằng nhiều báo sẽ bị chặn bởi `frame-ancestors` hoặc `X-Frame-Options`. `iframe` không giải bài toán trích nội dung; nó chỉ là một cách hiển thị trang từ xa. ([HTML Standard](https://html.spec.whatwg.org/multipage/iframe-embed-object.html), [MDN: CSP frame-ancestors](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors), [RFC 7034](https://www.rfc-editor.org/info/rfc7034/), [MDN: Referrer-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy))
+
+### Vì sao không nên dựa vào `iframe`
+
+- Chủ trang quyết định có cho nhúng hay không qua `frame-ancestors` và `X-Frame-Options`; app không ép được. ([MDN: CSP frame-ancestors](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors), [RFC 7034](https://www.rfc-editor.org/info/rfc7034/))
+- Same-origin policy ngăn code của app tương tác sâu với nội dung cross-origin trong frame. ([MDN: same-origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Same-origin_policy))
+- Frontend `fetch()` HTML cross-origin thường bị chặn nếu không có CORS phù hợp, nên muốn lấy nội dung đọc được thì phải làm ở server hoặc dùng feed/license chính thức. ([MDN: CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS))
+
+### Hệ quả thực dụng
+
+Giải pháp ngắn nhất mà vẫn bền là “server-side extraction + original link”, không phải “client-side iframe reader”.
+
+## 2. Tín hiệu độ tin cậy/provenance nên hiển thị
+
+### Nên hiển thị
+
+- `publisher`: tên publisher và domain gốc. ([Schema.org `publisher`](https://schema.org/publisher))
+- `author`: tên tác giả và URL hồ sơ nếu có. Google còn khuyến nghị `author.url` hoặc `sameAs` để định danh tốt hơn. ([Google Article structured data](https://developers.google.com/search/docs/appearance/structured-data/article), [Schema.org `author`](https://schema.org/author), [Schema.org `sameAs`](https://schema.org/sameAs))
+- `published_at` và `modified_at`: ngày đăng và ngày sửa ở định dạng ISO 8601 nếu publisher cung cấp. ([Google Article structured data](https://developers.google.com/search/docs/appearance/structured-data/article), [Schema.org `dateModified`](https://schema.org/dateModified))
+- `canonical_url`: URL đại diện để tránh trùng lặp biến thể. ([Google canonicalization](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls), [RFC 6596](https://www.rfc-editor.org/info/rfc6596/))
+- `mainEntityOfPage`: tín hiệu cho biết trang này là trang chính mô tả thực thể/bài viết nào. ([Schema.org `mainEntityOfPage`](https://schema.org/mainEntityOfPage))
+- `reviewedBy` và `publishingPrinciples`: nếu có thì đây là tín hiệu quy trình biên tập/fact-checking, nhưng nên hiển thị là “publisher khai báo”, không phải chứng nhận độc lập. ([Schema.org `reviewedBy`](https://schema.org/reviewedBy), [Schema.org `publishingPrinciples`](https://schema.org/publishingPrinciples))
+- `isAccessibleForFree` và `license`: tín hiệu quyền truy cập và license, hữu ích cho chính sách hiển thị excerpt/lưu trữ. ([Schema.org `Article`](https://schema.org/Article), [Schema.org `license`](https://schema.org/license))
+- `c2pa_present`: nếu ảnh/video trong bài có Content Credentials thì có thể hiện “có provenance mã hóa”, nhưng đây là tín hiệu mạnh hơn cho media asset, không thay thế đánh giá bài viết văn bản. ([C2PA Content Credentials](https://spec.c2pa.org/specifications/specifications/2.3/specs/ContentCredentials.html))
+- `cross_source_count`: số nguồn độc lập cùng xác nhận cùng một thực thể/sự kiện. Đây là suy luận sản phẩm từ nhiều nguồn chứ không phải trường chuẩn web, nhưng rất phù hợp để tránh confidence “cố định”.
+
+### Không nên hiển thị
+
+- `True / False`
+- `Nguồn này đáng tin 92%`
+- `Đã xác minh` nếu hệ thống mới chỉ có metadata/scrape
+
+### Ngôn ngữ UI nên dùng
+
+- `Bài gốc`
+- `Bản xem nhanh do hệ thống trích xuất`
+- `Nguồn gốc`
+- `Tín hiệu xuất bản`
+- `Đồng thuận liên nguồn`
+- `Không đủ dữ kiện`
+- `Publisher chặn nhúng`
+- `Có metadata tác giả/publisher`
+- `Có dấu hiệu đã chỉnh sửa sau xuất bản`
+
+## 3. Data model tối thiểu nên có
+
+```ts
+type SourceItem = {
+ id: string;
+ sourceType: "news" | "web" | "registry" | "company_site";
+ originalUrl: string;
+ canonicalUrl?: string;
+ title?: string;
+ publisher?: { name: string; url?: string };
+ author?: Array<{ name: string; url?: string }>;
+ publishedAt?: string;
+ modifiedAt?: string;
+ accessedAt: string;
+ language?: string;
+ snippet?: string;
+ extractedText?: string; // excerpt ngắn, không phải full article mặc định
+ rights?: {
+ licenseUrl?: string;
+ isAccessibleForFree?: boolean;
+ robotsNoSnippet?: boolean;
+ dataNoSnippetObserved?: boolean;
+ };
+ provenance?: {
+ reviewedBy?: string[];
+ publishingPrinciplesUrl?: string;
+ c2paPresent?: boolean;
+ crossSourceCount?: number;
+ };
+ delivery?: {
+ iframeAllowed?: boolean;
+ fetchMethod: "search-snippet" | "server-extract" | "iframe";
+ };
+};
+```
+
+Phần tối thiểu thật sự cần cho UI trước mắt là: `originalUrl`, `canonicalUrl`, `title`, `publisher`, `author`, `publishedAt`, `modifiedAt`, `snippet`, `extractedText` ngắn, `crossSourceCount`, `iframeAllowed`, `licenseUrl/isAccessibleForFree`. Các field khác nên chỉ thêm khi thu được ổn định từ nguồn thực tế.
+
+## 4. Có cần người dùng cung cấp thêm nguồn không?
+
+Không bắt buộc. Hệ thống có thể tìm rộng trên web và báo chí làm mặc định; các chuẩn metadata ở trên vốn được thiết kế cho hệ sinh thái web mở, không phụ thuộc một publisher duy nhất. Tuy vậy, nên cho người dùng ba mức kiểm soát:
+
+1. `Search broadly`
+2. `Prefer these domains`
+3. `Only these domains`
+
+Đây là suy luận kiến trúc từ cách chuẩn web/structured data/canonical/robots hoạt động trên web mở, không phải một yêu cầu chuẩn bắt buộc. Về sản phẩm, broad search là mặc định hợp lý; user-provided sources nên là bộ lọc/boost/compliance control, không phải điều kiện để app hoạt động.
+
+## 5. Privacy, copyright, security caveats
+
+### Security
+
+Server-side fetching mở ra bề mặt SSRF; phải chặn scheme nguy hiểm, private IP/ranges nội bộ, redirect chain bất thường, và giới hạn timeout/kích thước/content-type. ([OWASP SSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html))
+
+Nếu render HTML trích xuất, phải sanitize nghiêm ngặt; OWASP nêu rõ framework hiện đại vẫn có lỗ hổng khi dùng đường tắt như HTML injection trực tiếp. Mặc định an toàn hơn là render plain text hoặc HTML whitelist rất hẹp. ([OWASP XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html))
+
+Nếu vẫn mở link/iframe tới publisher, nên đặt `referrerpolicy` chặt để tránh rò URL nội bộ hoặc query nhạy cảm qua header `Referer`. ([MDN: Referrer-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy), [HTML Standard](https://html.spec.whatwg.org/multipage/iframe-embed-object.html))
+
+### Copyright và publisher intent
+
+Google Search Central mô tả rõ snippet được tạo từ nội dung trang, và publisher có thể dùng `nosnippet`, `max-snippet`, `data-nosnippet` để hạn chế phần text được trích hiển thị trong search. Các cơ chế này ràng buộc Google chứ không tự động ràng buộc app của mình, nhưng chúng là tín hiệu rõ về ý định của publisher; nên coi đó là policy input cho việc trích excerpt và cache nội dung. ([Google snippet controls](https://developers.google.com/search/docs/appearance/snippet), [Google robots meta tags](https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag), [Google special tags](https://developers.google.com/search/docs/crawling-indexing/special-tags))
+
+`robots.txt` không phải cơ chế bảo mật và không buộc mọi crawler phải tuân thủ; vì vậy không nên diễn giải “không bị chặn robots” thành “được phép làm mọi thứ với nội dung”. ([Google robots.txt intro](https://developers.google.com/search/docs/crawling-indexing/robots/intro))
+
+Ngưỡng an toàn thực dụng:
+
+- mặc định chỉ lưu `metadata + snippet + excerpt ngắn + hash`, không lưu full HTML/article body vĩnh viễn;
+- ưu tiên mở bài gốc thay vì tái bản toàn văn trong app;
+- nếu một nguồn có `license` rõ ràng hoặc feed/licensing chính thức, mới nâng mức lưu trữ/hiển thị;
+- nếu nguồn đánh dấu `isAccessibleForFree = false` hoặc là paywalled/subscription, chỉ nên hiển thị metadata và trích đoạn rất ngắn.
+
+## 6. Đề xuất product tối giản cho TechBridgeAI
+
+Mỗi finding nên có hai tầng:
+
+1. `Evidence card`: tiêu đề, publisher, ngày, snippet/excerpt, nút `Bài gốc`.
+2. `Source signals`: author, canonical URL, modified date, reviewedBy/publishingPrinciples nếu có, đồng thuận liên nguồn.
+
+Điểm “confidence” hiện tại nên đổi thành `signal summary`, ví dụ:
+
+- `3 nguồn độc lập, có publisher + author + ngày đăng`
+- `1 nguồn duy nhất, thiếu author, bài đã sửa sau xuất bản`
+- `Publisher chặn nhúng; chỉ hiển thị metadata và bài gốc`
+
+Đó là cách nói trung thực hơn với dữ liệu hệ thống thực sự có.
diff --git a/docs/superpowers/plans/2026-08-25-partneriq-langgraph-langfuse.md b/docs/superpowers/plans/2026-08-25-partneriq-langgraph-langfuse.md
index b1f12f9..b86e90c 100644
--- a/docs/superpowers/plans/2026-08-25-partneriq-langgraph-langfuse.md
+++ b/docs/superpowers/plans/2026-08-25-partneriq-langgraph-langfuse.md
@@ -37,7 +37,7 @@
| `src/modules/research/evidence.ts` | Create | Validate, deduplicate, prioritize, and frame findings |
| `src/modules/research/budget.ts` | Create | Enforce per-run call/token/provider concurrency budgets |
| `src/modules/research/index.ts` | Modify | Expose existing source runners without sequential orchestration |
-| `src/modules/workflow/state.ts` | Create | LangGraph state schema and append reducers |
+| `src/modules/workflow/state.ts` | Create | LangGraph state schema and source-result reducer |
| `src/modules/workflow/index.ts` | Create | Build/compile graph, nodes, edges, and custom event stream |
| `src/adapters/llm/openai.ts` | Modify | Implement existing LLM port with LangChain ChatOpenAI |
| `src/adapters/llm/types.ts` | Modify | Carry usage/cancellation/callback context without exposing LangChain types |
@@ -253,6 +253,7 @@ git commit -m "feat(research): prepare deterministic evidence"
- Create: `src/modules/research/queries.ts`
- Create: `src/modules/research/budget.ts`
+- Modify: `src/adapters/llm/types.ts`
- Modify: `src/modules/research/sources/web-search.ts`
- Modify: `src/modules/research/sources/news.ts`
- Modify: `src/config/index.ts`
@@ -280,6 +281,18 @@ export interface ResearchBudget {
}
```
+Define the provider-neutral contract in `src/adapters/llm/types.ts`:
+
+```ts
+export interface LLMBudget {
+ claimModelCall(estimatedInputTokens: number): void;
+ recordModelUsage(usage: LLMUsageLog): void;
+}
+```
+
+`ResearchBudget` implements `LLMBudget`; the LLM adapter must not import the
+research module.
+
- [ ] **Step 1: Write failing query-plan tests**
```ts
@@ -302,7 +315,7 @@ The ordered categories are identity, products/services, leadership, recent activ
```ts
it("rejects before a model call exceeds the run budget", () => {
const budget = createResearchBudget({
- maxLLMCalls: 1,
+ maxLLMCalls: 5,
maxTokens: 100,
maxConcurrentProviderCalls: 2,
});
@@ -357,7 +370,7 @@ Expected: PASS.
- [ ] **Step 8: Commit**
```bash
-git add src/modules/research/queries.ts src/modules/research/budget.ts src/modules/research/sources/web-search.ts src/modules/research/sources/news.ts src/config/index.ts .env.example tests/unit/research-queries.test.ts tests/unit/research-budget.test.ts tests/unit/sources.test.ts
+git add src/modules/research/queries.ts src/modules/research/budget.ts src/adapters/llm/types.ts src/modules/research/sources/web-search.ts src/modules/research/sources/news.ts src/config/index.ts .env.example tests/unit/research-queries.test.ts tests/unit/research-budget.test.ts tests/unit/sources.test.ts
git commit -m "feat(research): enforce bounded query budgets"
```
@@ -377,7 +390,12 @@ git commit -m "feat(research): enforce bounded query budgets"
export interface LLMInvocationContext {
signal?: AbortSignal;
callbacks?: readonly unknown[];
- budget?: ResearchBudget;
+ budget?: LLMBudget;
+}
+
+export interface LLMBudget {
+ claimModelCall(estimatedInputTokens: number): void;
+ recordModelUsage(usage: LLMUsageLog): void;
}
export interface LLMOptions {
@@ -466,6 +484,17 @@ export interface ResearchWorkflowOptions {
callbacks?: readonly unknown[];
}
+export interface ResearchWorkflowDeps {
+ llm: LLMAdapter;
+ search: SearchAdapter;
+ scraper: ScraperAdapter;
+ registry: RegistryAdapter;
+ storage: StorageAdapter;
+ profile: ProfileModule;
+ analyst: AnalystModule;
+ guards: ResourceGuards;
+}
+
export interface ResearchWorkflow {
stream(
input: CompanyInput,
@@ -504,7 +533,7 @@ Expected: FAIL because the workflow graph is absent.
- [ ] **Step 5: Define Zod-backed graph state and reducers**
-`state.ts` owns the state keys from the spec. The `sourceResults` and `findings` reducers append arrays. Defaults are empty arrays/null values; no adapter or function is stored in state.
+`state.ts` owns the state keys from the spec. Only `sourceResults` uses an append reducer. `prepare_evidence` derives and overwrites `findings`, avoiding duplicate parallel writes. Defaults are empty arrays/null values; no adapter or function is stored in state.
- [ ] **Step 6: Refactor source construction without changing source logic**
@@ -683,13 +712,12 @@ const workflow = createResearchWorkflow(deps);
for await (const event of workflow.stream(input, {
researchRunId,
signal: req.signal,
- callbacks,
})) {
writer.write(event);
}
```
-Use one `closeWriter()` guard in `finally`; do not close in intermediate branches. Export `runtime = "nodejs"`. Configure `maxDuration` to the active Vercel-plan value during deployment, and keep the internal workflow deadline at least ten seconds shorter.
+Use one `closeWriter()` guard in `finally`; do not close in intermediate branches. Export `runtime = "nodejs"` and `maxDuration = 300`. Enforce a 285-second internal deadline so terminal SSE output and Langfuse flush retain a 15-second margin.
- [ ] **Step 5: Propagate abort through adapters**
@@ -804,13 +832,13 @@ Set root `WARNING` for partial success and `ERROR` for failed outcome. Flush onc
```dotenv
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
-LANGFUSE_BASE_URL=https://jp.cloud.langfuse.com
+LANGFUSE_BASE_URL=
LANGFUSE_TRACING_ENVIRONMENT=production
LANGFUSE_LOG_LEVEL=WARN
LANGFUSE_ENABLED=true
```
-Document that the Japan region is the selected default for latency proximity; changing region requires changing the Langfuse project/account endpoint. When `LANGFUSE_ENABLED` is not `true`, use a no-op callback and skip export.
+Require `LANGFUSE_BASE_URL` to match the endpoint shown by the selected Langfuse Cloud project; do not hard-code a region in application code. When `LANGFUSE_ENABLED` is not `true`, use a no-op callback and skip export.
- [ ] **Step 7: Run focused observability and E2E tests**
@@ -873,7 +901,7 @@ README must include:
- exact install/runtime requirements;
- Vercel environment variables and `maxDuration` rule;
-- Langfuse Cloud setup and Japan endpoint;
+- Langfuse Cloud setup and project-region endpoint;
- what data is and is not exported;
- cancellation behavior;
- concurrency/query/token defaults;
diff --git a/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/README.md b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/README.md
new file mode 100644
index 0000000..8924bbf
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/README.md
@@ -0,0 +1,50 @@
+# Supabase Research Cache Sprint Roadmap
+
+**Spec:** [`../../specs/2026-08-26-supabase-research-cache-design.md`](../../specs/2026-08-26-supabase-research-cache-design.md)
+
+This roadmap splits the approved cache design into dependency-ordered,
+independently reviewable deliverables. A sprint is a technical delivery slice,
+not a calendar estimate. Execute and review one sprint before starting its
+successor.
+
+## Sprint sequence
+
+| Sprint | Deliverable | Depends on | Completion gate |
+|---|---|---|---|
+| [01 — Contracts and normalization](./sprint-01-contracts-and-normalization.md) | Typed request/SSE/snapshot contracts, safe identity normalization, pure lookup decisions | Approved spec | Focused unit tests, typecheck, Node 22 build |
+| [02 — Supabase schema and storage](./sprint-02-supabase-schema-and-storage.md) | Identity schema, transactional RPCs, complete-snapshot storage adapter | Sprint 01 | Local database reset, adapter tests, two-client concurrency test |
+| [03 — Server read-through flow](./sprint-03-server-read-through-flow.md) | Cache-first route, lazy paid providers, canonical workflow identity, atomic persist | Sprint 02 | Route/workflow tests prove hit/miss/conflict/error behavior |
+| [04 — Client suggestions and refresh](./sprint-04-client-suggestions-and-refresh.md) | Safe company confirmation, cache metadata, manual refresh UI | Sprint 03 | State-transition tests plus visual before/after verification |
+| [05 — Telemetry and release hardening](./sprint-05-telemetry-and-release-hardening.md) | HMAC telemetry, remaining negative-path coverage, advisors and release checks | Sprint 04 | Full test/lint/typecheck/build and database advisor pass |
+
+## Dependency flow
+
+```text
+contracts → database/storage → server/workflow → client/UI → hardening/release
+```
+
+## Cross-sprint constraints
+
+- Supabase remains the only shared persistent cache. Do not add localStorage,
+ Redis, or an in-process LRU.
+- Cache entries do not expire. Only explicit refresh creates a new version.
+- Tax ID may auto-match; a unique domain may auto-match; a normalized name
+ only produces suggestions.
+- Never trust a client-provided company ID without rebinding it to the current
+ normalized input.
+- Never auto-merge identities.
+- A cache hit, invalid selection, identity conflict, or unavailable cache must
+ not construct or call LLM, Serper, or scraper adapters.
+- Every started SSE stream ends with `done`, including fatal failures.
+- Runtime-validate cached JSONB before returning it.
+- Use `pg_advisory_xact_lock`, never a session advisory lock.
+- Use HMAC-SHA256 for telemetry fingerprints; never emit raw tax IDs/domains.
+- No refresh rate limiter is added in these sprints. The spec records it as a
+ later security/cost control.
+
+## Execution rule
+
+Each sprint document is a standalone implementation plan. The executor reads
+the approved spec and the selected sprint only, performs its test-first tasks,
+and stops at that sprint's review gate. Do not batch multiple sprint commits
+without review.
diff --git a/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-01-contracts-and-normalization.md b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-01-contracts-and-normalization.md
new file mode 100644
index 0000000..9591b98
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-01-contracts-and-normalization.md
@@ -0,0 +1,490 @@
+# Sprint 01 — Cache Contracts and Normalization Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Establish the runtime-validated request, SSE, snapshot, normalization,
+and pure cache-decision contracts without changing the research route behavior.
+
+**Architecture:** Add domain contracts to `src/lib/types.ts` and one focused
+cache module containing pure normalization, snapshot validation, and lookup
+decision logic. No Supabase I/O belongs in this sprint. Upgrade the production
+container to Node.js 22 because current Supabase client releases no longer
+support Node.js 20.
+
+**Tech Stack:** Next.js 16.3.2, TypeScript, Zod 4.4.3, Vitest 4.1.11, Node.js 22,
+platform `URL` and `crypto.randomUUID()` APIs.
+
+**Spec:** `docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md`
+
+## Global Constraints
+
+- Do not change the current `/api/research` runtime flow in this sprint.
+- Do not add a cache dependency, localStorage, Redis, or an LRU.
+- Preserve Vietnamese diacritics and legal suffixes in normalized names.
+- A normalized name never creates an automatic hit.
+- Runtime schemas must reject malformed cached JSONB instead of casting it.
+- Do not emit raw tax IDs or domains to logs or telemetry.
+- Match the existing Zod/type style in `src/lib/types.ts`.
+- Read `node_modules/next/dist/docs/01-app/01-getting-started/15-route-handlers.md`
+ before later route work; no Next.js route change occurs in this sprint.
+- Stage only files named by each task.
+
+---
+
+## File map
+
+| File | Action | Responsibility |
+|---|---|---|
+| `Dockerfile` | Modify | Move production runtime from Node 20 to Node 22 |
+| `src/lib/types.ts` | Modify | Request, cache, snapshot, and SSE runtime contracts |
+| `src/modules/cache/index.ts` | Create | Pure identity normalization, snapshot validation, and cache decision logic |
+| `tests/unit/types-validation.test.ts` | Modify | Request-union and complete-snapshot schema coverage |
+| `tests/unit/research-cache.test.ts` | Create | Normalization and lookup decision coverage |
+
+### Task 1: Define request and SSE contracts
+
+**Files:**
+
+- Modify: `src/lib/types.ts`
+- Modify: `tests/unit/types-validation.test.ts`
+
+**Interfaces:**
+
+- Consumes: existing `CompanyInputSchema`, `CompanyProfile`, `ProfileDiff`,
+ `AnalysisReport`, and `StreamEvent`.
+- Produces:
+
+```ts
+export const CacheActionSchema: z.ZodType<
+ | { action: "select"; companyId: string }
+ | { action: "refresh"; companyId: string }
+ | { action: "bypass" }
+>;
+
+export const ResearchRequestSchema: z.ZodType<{
+ input: CompanyInput;
+ cache?: z.infer;
+}>;
+
+export type ResearchErrorCode =
+ | "identity_conflict"
+ | "cache_invalid"
+ | "persist_failed"
+ | "research_failed";
+
+export type CacheHitMatchedBy = "tax_id" | "domain" | "selected";
+```
+
+- [ ] **Step 1: Write failing request-union tests**
+
+Append these cases to `tests/unit/types-validation.test.ts`:
+
+```ts
+import {
+ ResearchRequestSchema,
+ type ResearchRequest,
+} from "@/lib/types";
+
+it("accepts default, select, refresh, and bypass research requests", () => {
+ const requests: ResearchRequest[] = [
+ { input: { name: "FPT" } },
+ { input: { name: "FPT" }, cache: { action: "select", companyId: "fpt" } },
+ { input: { name: "FPT" }, cache: { action: "refresh", companyId: "fpt" } },
+ { input: { name: "FPT" }, cache: { action: "bypass" } },
+ ];
+
+ expect(requests.every((request) => ResearchRequestSchema.safeParse(request).success))
+ .toBe(true);
+});
+
+it("rejects cache actions with missing or unexpected company IDs", () => {
+ expect(
+ ResearchRequestSchema.safeParse({
+ input: { name: "FPT" },
+ cache: { action: "select" },
+ }).success,
+ ).toBe(false);
+ expect(
+ ResearchRequestSchema.safeParse({
+ input: { name: "FPT" },
+ cache: { action: "bypass", companyId: "injected" },
+ }).success,
+ ).toBe(false);
+});
+```
+
+- [ ] **Step 2: Run the request tests and confirm the missing exports fail**
+
+Run: `npm test -- tests/unit/types-validation.test.ts`
+
+Expected: FAIL because `ResearchRequestSchema` and `ResearchRequest` do not
+exist.
+
+- [ ] **Step 3: Implement the request schemas and stream-event additions**
+
+Add to `src/lib/types.ts`:
+
+```ts
+export const CacheActionSchema = z.discriminatedUnion("action", [
+ z.object({ action: z.literal("select"), companyId: z.string().min(1) }).strict(),
+ z.object({ action: z.literal("refresh"), companyId: z.string().min(1) }).strict(),
+ z.object({ action: z.literal("bypass") }).strict(),
+]);
+
+export const ResearchRequestSchema = z.object({
+ input: CompanyInputSchema,
+ cache: CacheActionSchema.optional(),
+}).strict();
+
+export type ResearchRequest = z.infer;
+export type ResearchErrorCode =
+ | "identity_conflict"
+ | "cache_invalid"
+ | "persist_failed"
+ | "research_failed";
+export type CacheHitMatchedBy = "tax_id" | "domain" | "selected";
+
+export interface CacheSuggestion {
+ companyId: string;
+ officialName: string;
+ taxId?: string;
+ domain?: string;
+ lastSyncedAt: string;
+}
+```
+
+Extend `StreamEvent` with `cache:hit` and `cache:suggestions`, and extend the
+existing error payload with optional `code: ResearchErrorCode`. Use the exact
+payloads from spec section 8.
+
+- [ ] **Step 4: Run focused validation and type checks**
+
+Run:
+
+```bash
+npm test -- tests/unit/types-validation.test.ts
+npm run typecheck
+```
+
+Expected: tests and typecheck pass.
+
+- [ ] **Step 5: Commit the request boundary**
+
+```bash
+git add src/lib/types.ts tests/unit/types-validation.test.ts
+git commit -m "feat(cache): define cache request contracts"
+```
+
+### Task 2: Add complete snapshot runtime schemas
+
+**Files:**
+
+- Modify: `src/lib/types.ts`
+- Modify: `tests/unit/types-validation.test.ts`
+
+**Interfaces:**
+
+- Produces:
+
+```ts
+export interface ResearchSnapshot {
+ profile: CompanyProfile;
+ report: AnalysisReport;
+ diff: ProfileDiff | null;
+ lastSyncedAt: string;
+}
+
+export const ResearchSnapshotSchema: z.ZodType;
+```
+
+- [ ] **Step 1: Write failing complete/corrupt snapshot tests**
+
+Use the existing valid profile fixtures or construct the minimum full objects:
+
+```ts
+it("parses a complete research snapshot and restores dates", () => {
+ const result = ResearchSnapshotSchema.parse({
+ profile: validProfileJson,
+ report: validReportJson,
+ diff: null,
+ lastSyncedAt: "2026-08-26T08:00:00.000Z",
+ });
+
+ expect(result.profile.lastUpdated).toBeInstanceOf(Date);
+ expect(result.report.generatedAt).toBeInstanceOf(Date);
+});
+
+it("rejects mismatched and incomplete snapshots", () => {
+ expect(() => ResearchSnapshotSchema.parse({
+ profile: validProfileJson,
+ report: { ...validReportJson, companyId: "other-company" },
+ diff: null,
+ lastSyncedAt: "2026-08-26T08:00:00.000Z",
+ })).toThrow();
+});
+```
+
+Define `validProfileJson` with every current `CompanyProfile` field and
+`validReportJson` with every current `AnalysisReport` field. Do not cast a
+partial object to bypass the schema.
+
+- [ ] **Step 2: Run and verify the schema test fails**
+
+Run: `npm test -- tests/unit/types-validation.test.ts`
+
+Expected: FAIL because `ResearchSnapshotSchema` is absent.
+
+- [ ] **Step 3: Implement runtime schemas that mirror the domain types**
+
+Add Zod schemas for `Address`, `Person`, `Activity`, `SourceCitation`,
+`CompanyProfile`, `FieldChange`, `ProfileDiff`, `FitScore`, `RiskFlag`,
+`SuggestedAction`, `AnalysisReport`, and `ResearchSnapshot`. Use
+`z.coerce.date()` for persisted date values, numeric bounds already enforced by
+the analyst/profile modules, and `.strict()` on trust-boundary objects.
+
+Add this final cross-object validation:
+
+```ts
+export const ResearchSnapshotSchema = z.object({
+ profile: CompanyProfileSchema,
+ report: AnalysisReportSchema,
+ diff: ProfileDiffSchema.nullable(),
+ lastSyncedAt: z.string().datetime(),
+}).strict().superRefine((snapshot, ctx) => {
+ if (snapshot.report.companyId !== snapshot.profile.id) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["report", "companyId"],
+ message: "Analysis report companyId must match profile id",
+ });
+ }
+ if (
+ snapshot.diff &&
+ (snapshot.diff.companyId !== snapshot.profile.id ||
+ snapshot.diff.toVersion !== snapshot.profile.version)
+ ) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["diff"],
+ message: "Profile diff must match profile id and version",
+ });
+ }
+});
+```
+
+- [ ] **Step 4: Run validation tests and typecheck**
+
+Run:
+
+```bash
+npm test -- tests/unit/types-validation.test.ts
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit runtime snapshot validation**
+
+```bash
+git add src/lib/types.ts tests/unit/types-validation.test.ts
+git commit -m "feat(cache): validate cached research snapshots"
+```
+
+### Task 3: Normalize identity and decide cache outcomes
+
+**Files:**
+
+- Create: `src/modules/cache/index.ts`
+- Create: `tests/unit/research-cache.test.ts`
+
+**Interfaces:**
+
+```ts
+export interface NormalizedCompanyIdentity {
+ taxId: string | null;
+ domain: string | null;
+ name: string;
+}
+
+export interface IdentityCandidate {
+ companyId: string;
+ taxId: string | null;
+ domain: string | null;
+ name: string;
+}
+
+export type CacheDecision =
+ | { kind: "hit"; companyId: string; matchedBy: "tax_id" | "domain" }
+ | { kind: "suggestions"; companyIds: string[] }
+ | { kind: "miss" }
+ | { kind: "conflict"; taxCompanyId: string; domainCompanyIds: string[] };
+
+export function normalizeCompanyIdentity(input: CompanyInput): NormalizedCompanyIdentity;
+export function decideCacheLookup(
+ identity: NormalizedCompanyIdentity,
+ candidates: readonly IdentityCandidate[],
+): CacheDecision;
+```
+
+- [ ] **Step 1: Write failing normalization tests**
+
+```ts
+it("normalizes tax ID, domain, and Vietnamese name without dropping legal suffixes", () => {
+ expect(normalizeCompanyIdentity({
+ name: " CÔNG TY CP Ánh Dương ",
+ taxId: "0101-245.486",
+ website: "https://WWW.Example.VN:443/about?q=1",
+ })).toEqual({
+ taxId: "0101245486",
+ domain: "example.vn",
+ name: "công ty cp ánh dương",
+ });
+});
+
+it("rejects a malformed supplied tax ID", () => {
+ expect(() => normalizeCompanyIdentity({ name: "FPT", taxId: "abc" }))
+ .toThrow("Mã số thuế phải có 10 hoặc 13 chữ số");
+});
+```
+
+- [ ] **Step 2: Write failing decision-table tests**
+
+Cover these exact cases:
+
+```ts
+expect(decideCacheLookup(withTaxAndDomain, candidatesForSameCompany)).toEqual({
+ kind: "hit", companyId: "company-a", matchedBy: "tax_id",
+});
+expect(decideCacheLookup(withConflictingKeys, conflictingCandidates)).toEqual({
+ kind: "conflict",
+ taxCompanyId: "company-a",
+ domainCompanyIds: ["company-b"],
+});
+expect(decideCacheLookup(domainOnly, twoDomainCandidates)).toEqual({
+ kind: "suggestions", companyIds: ["company-a", "company-b"],
+});
+expect(decideCacheLookup(nameOnly, oneNameCandidate)).toEqual({
+ kind: "suggestions", companyIds: ["company-a"],
+});
+expect(decideCacheLookup(nameOnly, [])).toEqual({ kind: "miss" });
+```
+
+- [ ] **Step 3: Run and verify the new test fails**
+
+Run: `npm test -- tests/unit/research-cache.test.ts`
+
+Expected: FAIL because `@/modules/cache` does not exist.
+
+- [ ] **Step 4: Implement normalization with platform APIs**
+
+Use these rules directly:
+
+```ts
+const TAX_ID_PATTERN = /^\d{10}(?:\d{3})?$/;
+
+function normalizeTaxId(value?: string): string | null {
+ if (!value) return null;
+ const normalized = value.trim().replace(/[\s.-]/g, "");
+ if (!TAX_ID_PATTERN.test(normalized)) {
+ throw new Error("Mã số thuế phải có 10 hoặc 13 chữ số");
+ }
+ return normalized;
+}
+
+function normalizeDomain(website?: string): string | null {
+ if (!website) return null;
+ return new URL(website).hostname
+ .toLowerCase()
+ .replace(/\.$/, "")
+ .replace(/^www\./, "");
+}
+
+function normalizeName(name: string): string {
+ return name.normalize("NFKC").trim().toLocaleLowerCase("vi-VN")
+ .replace(/\s+/g, " ");
+}
+```
+
+Implement `decideCacheLookup` as the spec decision table. Sort and deduplicate
+suggestion IDs before returning them so database row order cannot affect output.
+
+- [ ] **Step 5: Run cache tests and typecheck**
+
+Run:
+
+```bash
+npm test -- tests/unit/research-cache.test.ts tests/unit/types-validation.test.ts
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit pure cache behavior**
+
+```bash
+git add src/modules/cache/index.ts tests/unit/research-cache.test.ts
+git commit -m "feat(cache): normalize and resolve identities"
+```
+
+### Task 4: Align the production runtime with supported Node.js
+
+**Files:**
+
+- Modify: `Dockerfile`
+
+**Interfaces:**
+
+- Consumes: the existing multi-stage Docker build.
+- Produces: the same image layout running Node.js 22 Alpine.
+
+- [ ] **Step 1: Confirm the current base is unsupported**
+
+Read `Dockerfile` and confirm it currently contains:
+
+```dockerfile
+FROM node:20-alpine AS base
+```
+
+The Supabase 2026 changelog states current client libraries dropped Node.js 20
+support. CI already uses Node.js 22.
+
+- [ ] **Step 2: Make the one-line runtime update**
+
+```dockerfile
+FROM node:22-alpine AS base
+```
+
+- [ ] **Step 3: Verify production compilation**
+
+Run:
+
+```bash
+npm run typecheck
+npm run build
+```
+
+Expected: both commands pass under the development environment; the Docker
+stages remain otherwise unchanged.
+
+- [ ] **Step 4: Commit the runtime prerequisite**
+
+```bash
+git add Dockerfile
+git commit -m "chore(runtime): move production to node 22"
+```
+
+## Sprint 01 review gate
+
+Run:
+
+```bash
+npm test -- tests/unit/types-validation.test.ts tests/unit/research-cache.test.ts
+npm run lint
+npm run typecheck
+npm run build
+git status --short
+```
+
+Expected: all checks pass and the worktree contains no uncommitted Sprint 01
+files. Review the exported types before starting Sprint 02; later sprint plans
+use these names exactly.
diff --git a/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-02-supabase-schema-and-storage.md b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-02-supabase-schema-and-storage.md
new file mode 100644
index 0000000..4b16629
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-02-supabase-schema-and-storage.md
@@ -0,0 +1,573 @@
+# Sprint 02 — Supabase Schema and Storage Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add the canonical company identity schema, transactional database
+functions, complete-snapshot persistence, and matching memory/Supabase storage
+behavior without changing the research route.
+
+**Architecture:** Keep `StorageAdapter` as the existing persistence seam and
+deepen it with identity/cache methods. Supabase implements multi-statement
+identity creation and snapshot persistence through transaction-scoped RPCs;
+the memory adapter provides deterministic parity for route/workflow tests.
+
+**Tech Stack:** PostgreSQL 17-compatible SQL, Supabase Data API/PostgREST,
+`@supabase/supabase-js` 2.112.x, pinned Supabase CLI 2.115.0, TypeScript,
+Vitest 4.1.11.
+
+**Spec:** `docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md`
+
+## Global Constraints
+
+- Sprint 01 types and function names are fixed inputs.
+- Use `pg_advisory_xact_lock`; session-level advisory locks are forbidden.
+- Tax IDs are unique only when non-null; domains and names are not unique.
+- Never auto-merge identities or overwrite conflicting identity metadata.
+- Persist profile, report, diff, and identity metadata in one transaction.
+- Cache reads accept only profile rows with non-null `analysis_report`.
+- Database functions use `SECURITY INVOKER`, an empty search path, and fully
+ qualified relation names.
+- Revoke Data API access from `anon` and `authenticated`; grant only the
+ server-side `service_role` the required table/function access.
+- Never expose `SUPABASE_SERVICE_ROLE_KEY` through `NEXT_PUBLIC_*` or client
+ modules.
+- Create migrations with `supabase migration new`; do not hand-name migration
+ files.
+- Stage only files named by each task.
+
+---
+
+## File map
+
+| File | Action | Responsibility |
+|---|---|---|
+| `package.json` | Modify | Pin Supabase CLI and add the database test command |
+| `package-lock.json` | Modify | Lock CLI binaries and package graph |
+| `supabase/config.toml` | Create via CLI | Local Supabase project configuration |
+| `supabase/migrations/_research_cache.sql` | Create via CLI | Existing-database migration and RPC definitions |
+| `supabase/schema.sql` | Modify | Canonical fresh-project schema matching the migration |
+| `.env.example` | Modify | Server-only service-role configuration |
+| `src/adapters/storage/types.ts` | Modify | Identity and complete-snapshot storage interface |
+| `src/adapters/storage/memory.ts` | Modify | Test/development implementation |
+| `src/adapters/storage/supabase.ts` | Modify | RPC and complete-snapshot implementation |
+| `src/config/index.ts` | Modify | Require the server-only key for Supabase storage |
+| `tests/unit/adapters.test.ts` | Modify | Memory cache/storage parity |
+| `tests/unit/supabase-storage.test.ts` | Modify | RPC mapping and service-key validation |
+| `tests/integration/supabase-cache-concurrency.test.ts` | Create | Real transaction/concurrency verification |
+| `.github/workflows/ci.yml` | Modify | Run local Supabase database checks in CI |
+
+### Task 1: Pin and initialize the database toolchain
+
+**Files:**
+
+- Modify: `package.json`
+- Modify: `package-lock.json`
+- Create via CLI: `supabase/config.toml`
+
+**Interfaces:**
+
+- Consumes: Node.js 22 from Sprint 01 and Docker on developer/CI hosts.
+- Produces: reproducible `npx supabase` commands at version 2.115.0.
+
+- [ ] **Step 1: Install the stable CLI version verified for this plan**
+
+Run:
+
+```bash
+npm install --save-dev --save-exact supabase@2.115.0
+npx supabase --version
+npx supabase migration new --help
+npx supabase db advisors --help
+```
+
+Expected: version `2.115.0`; help lists `migration new` and local database
+advisor support. Stop and update this sprint document if the pinned commands do
+not match the installed help.
+
+- [ ] **Step 2: Initialize local Supabase configuration**
+
+Run:
+
+```bash
+npx supabase init
+```
+
+Expected: `supabase/config.toml` is created without replacing
+`supabase/schema.sql`.
+
+- [ ] **Step 3: Add an explicit database-test script**
+
+Add to `package.json`:
+
+```json
+"test:db": "vitest run tests/integration/supabase-cache-concurrency.test.ts"
+```
+
+- [ ] **Step 4: Verify package integrity and commit**
+
+Run:
+
+```bash
+npm ci
+npx supabase --version
+```
+
+Expected: clean install and CLI `2.115.0`.
+
+```bash
+git add package.json package-lock.json supabase/config.toml
+git commit -m "chore(db): pin supabase cli"
+```
+
+### Task 2: Add identity and complete-snapshot schema
+
+**Files:**
+
+- Create via CLI: `supabase/migrations/_research_cache.sql`
+- Modify: `supabase/schema.sql`
+
+**Interfaces:**
+
+- Produces tables/indexes described in spec section 4 and these RPCs:
+
+```sql
+public.lookup_company_identities(text, text, text)
+public.resolve_company_identity(text, text, text, text)
+public.persist_research_snapshot(text, text, text, text, integer, jsonb, jsonb, jsonb)
+```
+
+- [ ] **Step 1: Create the migration through the CLI**
+
+Run:
+
+```bash
+npx supabase migration new research_cache
+```
+
+Expected: CLI prints the exact new path under `supabase/migrations`. Use that
+printed path for every remaining migration edit and commit; do not rename it.
+
+- [ ] **Step 2: Add the identity table and profile/diff constraints**
+
+Write these statements into the generated migration and mirror them in
+`supabase/schema.sql` for fresh projects:
+
+```sql
+create table if not exists public.company_identities (
+ id text primary key,
+ tax_id text,
+ normalized_domain text,
+ normalized_name text not null,
+ created_at timestamptz not null default timezone('utc'::text, now()),
+ updated_at timestamptz not null default timezone('utc'::text, now())
+);
+
+create unique index if not exists idx_company_identities_tax_id
+ on public.company_identities (tax_id)
+ where tax_id is not null;
+create index if not exists idx_company_identities_domain
+ on public.company_identities (normalized_domain);
+create index if not exists idx_company_identities_name
+ on public.company_identities (normalized_name);
+
+alter table public.company_profiles
+ add column if not exists analysis_report jsonb;
+
+create index if not exists idx_company_profiles_complete
+ on public.company_profiles (id, version desc)
+ where analysis_report is not null;
+```
+
+Backfill `company_identities` from the latest row per existing profile ID before
+adding foreign keys. Use `data->>'taxId'`, `data->>'website'`, and
+`data->>'officialName'` with the same digit/domain/lowercase whitespace rules
+as Sprint 01. Invalid legacy tax IDs become null; never fail the migration on
+one malformed JSONB value.
+
+Then add foreign keys:
+
+```sql
+alter table public.company_profiles
+ add constraint company_profiles_identity_fk
+ foreign key (id) references public.company_identities(id);
+
+alter table public.company_diffs
+ add constraint company_diffs_identity_fk
+ foreign key (company_id) references public.company_identities(id);
+```
+
+Guard each named constraint with a `pg_constraint` existence check so rerunning
+the canonical schema is idempotent.
+
+- [ ] **Step 3: Replace broad public policies with server-only access**
+
+Drop the two existing “Allow anon read/write” policies, enable RLS on all three
+tables, revoke table access from `anon`/`authenticated`, and grant the minimum
+table privileges to `service_role`:
+
+```sql
+drop policy if exists "Allow anon read/write company_profiles"
+ on public.company_profiles;
+drop policy if exists "Allow anon read/write company_diffs"
+ on public.company_diffs;
+
+alter table public.company_identities enable row level security;
+alter table public.company_profiles enable row level security;
+alter table public.company_diffs enable row level security;
+
+revoke all on public.company_identities from anon, authenticated;
+revoke all on public.company_profiles from anon, authenticated;
+revoke all on public.company_diffs from anon, authenticated;
+
+grant select, insert, update on public.company_identities to service_role;
+grant select, insert, update on public.company_profiles to service_role;
+grant select, insert, update on public.company_diffs to service_role;
+```
+
+No browser/client code receives the service-role key.
+
+- [ ] **Step 4: Add the read-only lookup RPC**
+
+Implement `public.lookup_company_identities(p_tax_id text, p_domain text,
+p_name text)` as `LANGUAGE sql STABLE SECURITY INVOKER SET search_path = ''`.
+Return distinct identity rows matching any non-null supplied key. Fully qualify
+`public.company_identities` and order by `id` for deterministic adapter output.
+
+Revoke default execute and grant only server execution:
+
+```sql
+revoke execute on function public.lookup_company_identities(text, text, text)
+ from public, anon, authenticated;
+grant execute on function public.lookup_company_identities(text, text, text)
+ to service_role;
+```
+
+- [ ] **Step 5: Add the transactional resolve/create RPC**
+
+Implement `public.resolve_company_identity` with these exact branches:
+
+```plpgsql
+if p_tax_id is not null then
+ insert into public.company_identities (
+ id, tax_id, normalized_domain, normalized_name
+ ) values (
+ p_candidate_id, p_tax_id, p_domain, p_name
+ ) on conflict (tax_id) where tax_id is not null do nothing;
+
+ select id into resolved_id
+ from public.company_identities
+ where tax_id = p_tax_id;
+elsif p_domain is not null then
+ perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(p_domain));
+
+ select id into resolved_id
+ from public.company_identities
+ where normalized_domain = p_domain
+ and normalized_name = p_name
+ order by id
+ limit 1;
+
+ if resolved_id is null then
+ insert into public.company_identities (
+ id, normalized_domain, normalized_name
+ ) values (
+ p_candidate_id, p_domain, p_name
+ ) returning id into resolved_id;
+ end if;
+else
+ insert into public.company_identities (id, normalized_name)
+ values (p_candidate_id, p_name)
+ returning id into resolved_id;
+end if;
+```
+
+Before returning, detect supplied tax/domain disagreement and raise a stable
+exception marker `identity_conflict`. Do not update an existing identity's keys
+inside the tax-ID conflict branch.
+
+Declare the function `SECURITY INVOKER SET search_path = ''`, revoke execution
+from `public`, `anon`, and `authenticated`, and grant it to `service_role`.
+
+- [ ] **Step 6: Add atomic snapshot persistence RPC**
+
+Implement `public.persist_research_snapshot` as one PL/pgSQL transaction that:
+
+1. Locks the target `company_identities` row with `FOR UPDATE`.
+2. Rechecks the pipeline-derived tax ID and domain against other identities.
+3. Raises `identity_conflict` before mutation when either key belongs to a
+ different identity under the approved conflict rules.
+4. Updates only the target identity's non-conflicting normalized metadata.
+5. Upserts `company_profiles(id, version, official_name, data,
+ analysis_report, updated_at)` on `(id, version)`.
+6. Upserts the supplied diff when non-null; version 1 accepts null.
+7. Returns the authoritative `updated_at` value.
+
+Use `SECURITY INVOKER SET search_path = ''`, explicit schema qualification,
+server-only execute grants, and no dynamic SQL.
+
+- [ ] **Step 7: Apply and inspect the local schema**
+
+Run:
+
+```bash
+npx supabase start
+npx supabase db reset
+npx supabase migration list --local
+npx supabase db advisors --local
+```
+
+Expected: migration is applied, no duplicate/failed migration, and advisors
+report no security/performance issue introduced by these objects.
+
+- [ ] **Step 8: Commit schema and RPCs**
+
+```bash
+git add supabase/schema.sql supabase/migrations
+git commit -m "feat(db): add research cache schema"
+```
+
+### Task 3: Deepen the storage interface and memory adapter
+
+**Files:**
+
+- Modify: `src/adapters/storage/types.ts`
+- Modify: `src/adapters/storage/memory.ts`
+- Modify: `tests/unit/adapters.test.ts`
+
+**Interfaces:**
+
+Add these methods while retaining existing profile/diff methods until Sprint 03
+migrates the workflow:
+
+```ts
+findIdentityCandidates(
+ identity: NormalizedCompanyIdentity,
+ options?: StorageReadOptions,
+): Promise;
+
+getLatestCompleteSnapshot(
+ companyId: string,
+ options?: StorageReadOptions,
+): Promise;
+
+resolveOrCreateIdentity(
+ identity: NormalizedCompanyIdentity,
+ candidateId: string,
+ options?: StorageWriteOptions,
+): Promise;
+
+persistResearchSnapshot(
+ identity: NormalizedCompanyIdentity,
+ snapshot: Omit,
+ options?: StorageWriteOptions,
+): Promise;
+```
+
+- [ ] **Step 1: Write failing memory-adapter tests**
+
+Add tests proving:
+
+```ts
+await storage.resolveOrCreateIdentity(identity, "company-a");
+await storage.persistResearchSnapshot(identity, draft);
+
+await expect(storage.findIdentityCandidates(identity)).resolves.toEqual([
+ expect.objectContaining({ companyId: "company-a" }),
+]);
+await expect(storage.getLatestCompleteSnapshot("company-a")).resolves
+ .toMatchObject({ profile: { id: "company-a" }, report: { companyId: "company-a" } });
+```
+
+Add an invalid selection/conflicting tax-ID case and a version-2 snapshot whose
+diff matches `toVersion: 2`.
+
+- [ ] **Step 2: Run and verify interface failures**
+
+Run: `npm test -- tests/unit/adapters.test.ts`
+
+Expected: FAIL because the new methods do not exist.
+
+- [ ] **Step 3: Implement the minimum in-memory parity**
+
+Store identities in a `Map` and complete snapshots
+in the existing company/version maps. Reuse Sprint 01's `decideCacheLookup`
+rules; do not create a second normalization implementation. Return cloned
+arrays/objects where mutation would leak across tests.
+
+- [ ] **Step 4: Run focused tests and typecheck**
+
+Run:
+
+```bash
+npm test -- tests/unit/adapters.test.ts
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit the storage seam**
+
+```bash
+git add src/adapters/storage/types.ts src/adapters/storage/memory.ts tests/unit/adapters.test.ts
+git commit -m "feat(storage): add complete cache snapshots"
+```
+
+### Task 4: Implement Supabase storage and server-only credentials
+
+**Files:**
+
+- Modify: `.env.example`
+- Modify: `src/config/index.ts`
+- Modify: `src/adapters/storage/supabase.ts`
+- Modify: `tests/unit/supabase-storage.test.ts`
+
+**Interfaces:** Implements all Sprint 02 `StorageAdapter` methods via
+`.rpc(...)`, complete-row selection, and exact-version diff selection.
+
+- [ ] **Step 1: Write failing service-key and RPC mapping tests**
+
+Mock the Supabase client boundary and assert:
+
+- Supabase storage refuses startup without `SUPABASE_SERVICE_ROLE_KEY`.
+- Identity lookup calls `lookup_company_identities` with normalized values.
+- Identity creation calls `resolve_company_identity`.
+- Snapshot persistence calls `persist_research_snapshot`.
+- Complete snapshot selection filters `analysis_report` non-null, orders version
+ descending, then fetches diff by exact `company_id` and `to_version`.
+- Abort signals propagate to all PostgREST/RPC builders that support them.
+
+- [ ] **Step 2: Run and verify tests fail**
+
+Run: `npm test -- tests/unit/supabase-storage.test.ts`
+
+Expected: FAIL against the old adapter constructor/method set.
+
+- [ ] **Step 3: Require the server-only credential**
+
+Document in `.env.example`:
+
+```dotenv
+SUPABASE_URL=https://xyz.supabase.co
+SUPABASE_SERVICE_ROLE_KEY=server-only-secret
+```
+
+Remove the Supabase storage path's use of `SUPABASE_ANON_KEY` in
+`createStorageAdapter`. The adapter constructor accepts URL and service-role key
+only. Do not rename the key with a `NEXT_PUBLIC_` prefix.
+
+- [ ] **Step 4: Implement RPC and snapshot mapping**
+
+Call the three exact RPC names from Task 2. Parse every JSONB response through
+`ResearchSnapshotSchema`; do not use `as CompanyProfile` or
+`as AnalysisReport`. Convert known RPC conflict markers to a typed
+`IdentityConflictError` exported from `src/modules/cache/index.ts`; convert
+transport errors to a storage error without provider fallback.
+
+- [ ] **Step 5: Run focused tests**
+
+Run:
+
+```bash
+npm test -- tests/unit/supabase-storage.test.ts tests/unit/adapters.test.ts
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit Supabase adapter behavior**
+
+```bash
+git add .env.example src/config/index.ts src/adapters/storage/supabase.ts tests/unit/supabase-storage.test.ts
+git commit -m "feat(storage): use transactional supabase cache"
+```
+
+### Task 5: Prove transaction-level concurrency with two clients
+
+**Files:**
+
+- Create: `tests/integration/supabase-cache-concurrency.test.ts`
+- Modify: `.github/workflows/ci.yml`
+
+**Interfaces:** Uses `SUPABASE_TEST_URL` and
+`SUPABASE_TEST_SERVICE_ROLE_KEY`; never uses production credentials.
+
+- [ ] **Step 1: Write the real concurrent RPC test**
+
+Create two independent `createClient` instances, start both RPC calls before
+awaiting either, and assert one identity:
+
+```ts
+const first = createClient(testUrl, serviceKey, clientOptions);
+const second = createClient(testUrl, serviceKey, clientOptions);
+const domain = `race-${crypto.randomUUID()}.example`;
+const name = `race ${crypto.randomUUID()}`;
+
+const [a, b] = await Promise.all([
+ first.rpc("resolve_company_identity", {
+ p_tax_id: null,
+ p_domain: domain,
+ p_name: name,
+ p_candidate_id: crypto.randomUUID(),
+ }),
+ second.rpc("resolve_company_identity", {
+ p_tax_id: null,
+ p_domain: domain,
+ p_name: name,
+ p_candidate_id: crypto.randomUUID(),
+ }),
+]);
+
+expect(a.error).toBeNull();
+expect(b.error).toBeNull();
+expect(a.data).toBe(b.data);
+```
+
+Query `company_identities` with the service-role test client and assert exactly
+one row for that domain/name. Add a rollback test for post-pipeline conflict so
+no profile/diff row survives a failed persistence RPC.
+
+- [ ] **Step 2: Run against local Supabase**
+
+Run:
+
+```bash
+eval "$(npx supabase status -o env)"
+SUPABASE_TEST_URL="$API_URL" \
+SUPABASE_TEST_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" \
+npm run test:db
+```
+
+Expected: both concurrent calls return the same ID and all database integration
+tests pass.
+
+- [ ] **Step 3: Add the same local database gate to CI**
+
+After `npm ci`, add CI steps that run `npx supabase start`, export local API URL
+and service key only within the database-test step, run `npm run test:db`, then
+stop the stack with `npx supabase stop --no-backup`. Do not print service keys.
+
+- [ ] **Step 4: Commit concurrency verification**
+
+```bash
+git add tests/integration/supabase-cache-concurrency.test.ts .github/workflows/ci.yml
+git commit -m "test(db): verify cache identity locking"
+```
+
+## Sprint 02 review gate
+
+Run:
+
+```bash
+npx supabase db reset
+npx supabase migration list --local
+npx supabase db advisors --local
+eval "$(npx supabase status -o env)"
+SUPABASE_TEST_URL="$API_URL" SUPABASE_TEST_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" npm run test:db
+npm test -- tests/unit/adapters.test.ts tests/unit/supabase-storage.test.ts
+npm run lint
+npm run typecheck
+git status --short
+```
+
+Expected: migration/advisors pass, independent-client concurrency passes, unit
+tests pass, and the worktree is clean before Sprint 03.
diff --git a/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-03-server-read-through-flow.md b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-03-server-read-through-flow.md
new file mode 100644
index 0000000..3a21e2b
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-03-server-read-through-flow.md
@@ -0,0 +1,494 @@
+# Sprint 03 — Server Read-Through Flow Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Put the complete Supabase snapshot in front of the expensive research
+pipeline, bind every client selection to current input, and persist misses or
+refreshes atomically under a canonical company ID.
+
+**Architecture:** Deepen the pure cache module into a storage-backed research
+cache, then keep the route as the orchestration point. The workflow becomes
+storage-independent: it receives canonical identity/previous profile, produces
+a terminal state, and leaves final SSE emission/persistence to the route.
+
+**Tech Stack:** Next.js 16.3.2 Node Route Handler, Web Streams/SSE, LangGraph
+1.4.12, Zod 4.4.3, Supabase storage seam, Vitest 4.1.11.
+
+**Spec:** `docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md`
+
+## Global Constraints
+
+- Read the installed Next.js 16 route-handler and streaming guides before
+ editing `route.ts`.
+- Construct only storage/cache dependencies before cache resolution.
+- Paid provider factories must remain untouched on hit, invalid selection,
+ identity conflict, and cache-backend failure.
+- Recompute candidates server-side for every `select` and `refresh` request.
+- Never derive canonical identity with `slugify(name)`.
+- Workflow owns research computation; route owns persistence and final SSE
+ events.
+- Every started SSE stream ends with `done`.
+- A corrupt cache is recoverable; a persist failure or post-pipeline identity
+ conflict is fatal.
+- Do not add a queue, background job, rate limiter, TTL, or LRU.
+- Stage only files named by each task.
+
+---
+
+## File map
+
+| File | Action | Responsibility |
+|---|---|---|
+| `src/modules/cache/index.ts` | Modify | Storage-backed lookup, selection, refresh binding, and persistence interface |
+| `src/modules/workflow/state.ts` | Modify | Canonical ID and supplied previous profile in initial state |
+| `src/modules/workflow/index.ts` | Modify | Remove storage reads/writes and final result emission |
+| `src/app/api/research/route.ts` | Modify | Cache-first orchestration, HTTP/SSE errors, lazy providers, atomic persist |
+| `src/lib/stream.ts` | Modify only if required | Preserve explicit error/done ordering and close-once behavior |
+| `tests/unit/research-cache.test.ts` | Modify | Storage-backed selection/refresh/cache-invalid behavior |
+| `tests/integration/research-workflow.test.ts` | Modify | Storage-independent canonical workflow behavior |
+| `tests/unit/research-cache-route.test.ts` | Create | Hit/miss/selection/conflict/provider-construction route coverage |
+| `tests/unit/research-route-observability.test.ts` | Modify | New request body and route terminal behavior |
+| `tests/e2e/workflow-e2e.test.ts` | Modify | New request body and final SSE ownership |
+
+### Task 1: Build the storage-backed research cache module
+
+**Files:**
+
+- Modify: `src/modules/cache/index.ts`
+- Modify: `tests/unit/research-cache.test.ts`
+
+**Interfaces:**
+
+```ts
+export type CacheResolution =
+ | {
+ kind: "hit";
+ snapshot: ResearchSnapshot;
+ matchedBy: "tax_id" | "domain";
+ }
+ | { kind: "suggestions"; suggestions: CacheSuggestion[] }
+ | {
+ kind: "miss";
+ identity: NormalizedCompanyIdentity;
+ cacheInvalid: boolean;
+ }
+ | {
+ kind: "conflict";
+ taxCompanyId: string;
+ domainCompanyIds: string[];
+ };
+
+export interface ResearchCache {
+ lookup(input: CompanyInput, options?: StorageReadOptions): Promise;
+ select(
+ input: CompanyInput,
+ companyId: string,
+ options?: StorageReadOptions,
+ ): Promise;
+ prepareRefresh(
+ input: CompanyInput,
+ companyId: string,
+ options?: StorageReadOptions,
+ ): Promise;
+ resolveMiss(
+ input: CompanyInput,
+ options?: StorageWriteOptions,
+ ): Promise<{ companyId: string; identity: NormalizedCompanyIdentity }>;
+ persist(
+ identity: NormalizedCompanyIdentity,
+ snapshot: Omit,
+ options?: StorageWriteOptions,
+ ): Promise;
+}
+
+export function createResearchCache(storage: StorageAdapter): ResearchCache;
+```
+
+- [ ] **Step 1: Write failing hit/suggestion/miss tests**
+
+Use `MemoryStorageAdapter` and prove:
+
+```ts
+await expect(cache.lookup({ name: "FPT", taxId: "0101248141" }))
+ .resolves.toMatchObject({
+ kind: "hit",
+ matchedBy: "tax_id",
+ snapshot: { profile: { id: "company-a" } },
+ });
+
+await expect(cache.lookup({ name: "FPT" })).resolves.toMatchObject({
+ kind: "suggestions",
+ suggestions: [expect.objectContaining({ companyId: "company-a" })],
+});
+
+await expect(cache.lookup({ name: "Unknown" })).resolves.toEqual({
+ kind: "miss",
+ identity: { taxId: null, domain: null, name: "unknown" },
+ cacheInvalid: false,
+});
+```
+
+- [ ] **Step 2: Write the real unrelated-existing-ID tests**
+
+Seed complete snapshots for company A and company B. Assert:
+
+```ts
+await expect(cache.select({ name: "Company A" }, "company-b"))
+ .rejects.toMatchObject({ code: "invalid_cache_selection" });
+
+await expect(cache.prepareRefresh(
+ { name: "Company A", taxId: "tax-a" },
+ "company-b",
+)).rejects.toMatchObject({ code: "identity_conflict" });
+```
+
+The rejected IDs must exist and have valid snapshots; a nonexistent ID does not
+cover the input-binding vulnerability.
+
+- [ ] **Step 3: Write a corrupt-snapshot recovery test**
+
+Make `getLatestCompleteSnapshot` throw `CacheInvalidError` and assert `lookup`
+returns the same normalized miss with `cacheInvalid: true`. Transport/storage
+errors must propagate instead of becoming a miss.
+
+- [ ] **Step 4: Run and verify missing module behavior fails**
+
+Run: `npm test -- tests/unit/research-cache.test.ts`
+
+Expected: FAIL because `createResearchCache` and the typed cache errors are not
+implemented.
+
+- [ ] **Step 5: Implement the minimum cache orchestration**
+
+`lookup` calls `findIdentityCandidates`, passes the result to
+`decideCacheLookup`, then loads complete snapshots only for the chosen hit or
+suggestion IDs. Drop identities without a complete snapshot from suggestions.
+If no complete candidate remains, return a miss and retain the normalized
+identity for `resolveMiss`.
+
+`select` reruns the full lookup and accepts `companyId` only when it appears in
+the current suggestion set. `prepareRefresh` reruns lookup and accepts the ID
+only when the hit/suggestion set contains it without a strong-key conflict.
+
+`resolveMiss` calls:
+
+```ts
+storage.resolveOrCreateIdentity(
+ normalizeCompanyIdentity(input),
+ crypto.randomUUID(),
+ options,
+);
+```
+
+`persist` delegates once to `persistResearchSnapshot` and returns the database
+timestamped snapshot.
+
+- [ ] **Step 6: Run focused cache tests**
+
+Run:
+
+```bash
+npm test -- tests/unit/research-cache.test.ts tests/unit/adapters.test.ts
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit the cache module**
+
+```bash
+git add src/modules/cache/index.ts tests/unit/research-cache.test.ts
+git commit -m "feat(cache): bind cache decisions to input"
+```
+
+### Task 2: Make the workflow canonical-ID driven and storage independent
+
+**Files:**
+
+- Modify: `src/modules/workflow/state.ts`
+- Modify: `src/modules/workflow/index.ts`
+- Modify: `tests/integration/research-workflow.test.ts`
+
+**Interfaces:**
+
+Change workflow options/state initialization to require:
+
+```ts
+export interface ResearchWorkflowOptions {
+ researchRunId: string;
+ companyId: string;
+ existingProfile: CompanyProfile | null;
+ signal?: AbortSignal;
+ callbacks?: readonly unknown[];
+ sessionId?: string;
+ onComplete?: (state: ResearchWorkflowState) => void | Promise;
+}
+```
+
+Remove `storage` from `ResearchWorkflowDeps` after all workflow reads/writes are
+deleted.
+
+- [ ] **Step 1: Rewrite failing workflow tests around supplied identity**
+
+Replace storage-failure tests with these assertions:
+
+```ts
+const state = await workflow.run(
+ { name: "Different Display Name" },
+ {
+ researchRunId: "canonical-id",
+ companyId: "stable-company-id",
+ existingProfile,
+ },
+);
+
+expect(state.profile?.id).toBe("stable-company-id");
+expect(state.profile?.version).toBe(existingProfile.version + 1);
+expect(state.diff).toMatchObject({
+ companyId: "stable-company-id",
+ fromVersion: existingProfile.version,
+ toVersion: existingProfile.version + 1,
+});
+```
+
+Add a stream test proving source/progress/build events still appear but
+`profile:ready`, `diff:ready`, `analysis:ready`, and `done` do not; the route
+will own those final events.
+
+- [ ] **Step 2: Run and verify old workflow behavior fails the new assertions**
+
+Run: `npm test -- tests/integration/research-workflow.test.ts`
+
+Expected: FAIL because options do not accept canonical identity and the graph
+still reads/writes storage/emits final events.
+
+- [ ] **Step 3: Remove storage nodes and derive state from options**
+
+Initialize state with:
+
+```ts
+{
+ researchRunId: options.researchRunId,
+ input,
+ existingProfile: options.existingProfile,
+ // existing source/findings/profile/diff/report/outcome defaults remain
+}
+```
+
+Build the profile with `options.companyId`; calculate diff without saving it.
+Remove `load_existing_profile` and `persist_profile` nodes/edges. Remove final
+result custom events from diff/analyze nodes and remove the workflow-level
+`done` yield. Keep fatal/source error events and always invoke `onComplete` with
+the terminal state.
+
+- [ ] **Step 4: Delete storage from workflow callers/tests**
+
+Remove `storage` from `ResearchWorkflowDeps`, workflow construction, and test
+builders. Keep storage tests in the adapter/cache suites.
+
+- [ ] **Step 5: Run workflow tests and typecheck**
+
+Run:
+
+```bash
+npm test -- tests/integration/research-workflow.test.ts
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit workflow isolation**
+
+```bash
+git add src/modules/workflow/state.ts src/modules/workflow/index.ts tests/integration/research-workflow.test.ts
+git commit -m "refactor(workflow): accept canonical cache state"
+```
+
+### Task 3: Put cache lookup before all paid providers
+
+**Files:**
+
+- Modify: `src/app/api/research/route.ts`
+- Create: `tests/unit/research-cache-route.test.ts`
+- Modify: `tests/unit/research-route-observability.test.ts`
+
+**Interfaces:**
+
+- HTTP errors use `{ error: { code, message } }` and the status table from spec
+ section 12.
+- Started SSE streams use `cache:*`, current progress events, explicit error
+ codes, final result events, and `done`.
+
+- [ ] **Step 1: Write a cache-hit route test that forbids provider construction**
+
+Mock `createStorageAdapter` with a complete cached snapshot and define
+`createLLMAdapter`, `createSearchAdapter`, and `createScraperAdapter` as spies
+that throw if called. POST:
+
+```json
+{ "input": { "name": "FPT", "taxId": "0101248141" } }
+```
+
+Assert status 200 and this event order:
+
+```ts
+expect(body.indexOf("event: cache:hit")).toBeLessThan(body.indexOf("event: profile:ready"));
+expect(body.indexOf("event: profile:ready")).toBeLessThan(body.indexOf("event: diff:ready"));
+expect(body.indexOf("event: diff:ready")).toBeLessThan(body.indexOf("event: analysis:ready"));
+expect(body.trimEnd()).toContain("event: done");
+expect(createLLMAdapter).not.toHaveBeenCalled();
+expect(createSearchAdapter).not.toHaveBeenCalled();
+expect(createScraperAdapter).not.toHaveBeenCalled();
+```
+
+- [ ] **Step 2: Write pre-stream failure tests**
+
+Cover:
+
+- Existing unrelated `companyId` on `select` → `400 invalid_cache_selection`.
+- Strong-key disagreement → `409 identity_conflict`.
+- Storage/RPC transport failure → `503 cache_unavailable`.
+
+For every case, assert paid provider factories have zero calls.
+
+- [ ] **Step 3: Write miss and corrupt-cache tests**
+
+- Empty lookup: assert identity resolves and workflow/provider factories are
+ constructed without a second bypass request.
+- Corrupt snapshot: assert SSE contains `error.code = cache_invalid`, then
+ progress/persisted final events and `done`.
+
+- [ ] **Step 4: Run route tests and verify they fail**
+
+Run: `npm test -- tests/unit/research-cache-route.test.ts`
+
+Expected: FAIL against the current eager-provider route.
+
+- [ ] **Step 5: Implement cache-first route branching**
+
+Parse `ResearchRequestSchema`, create storage/cache, and resolve the cache before
+calling any paid-provider factory. Use these branches:
+
+```ts
+switch (resolution.kind) {
+ case "hit":
+ return streamCachedSnapshot(resolution);
+ case "suggestions":
+ return streamSuggestions(resolution.suggestions);
+ case "conflict":
+ return jsonError(409, "identity_conflict", "Thông tin định danh công ty mâu thuẫn.");
+ case "miss":
+ break;
+}
+```
+
+Handle `select`, `refresh`, and `bypass` before default lookup. `bypass` skips
+returning suggestions but still calls `resolveMiss`; it never accepts a client
+company ID.
+
+Only after identity resolution succeeds, construct providers/workflow and start
+the long-running SSE flow.
+
+- [ ] **Step 6: Centralize final events after atomic persistence**
+
+Capture workflow terminal state through `onComplete`. If it has profile/report
+and no fatal error, call `cache.persist` once. On success write:
+
+```ts
+writer.write({ event: "profile:ready", data: { profile: persisted.profile } });
+writer.write({ event: "diff:ready", data: { diff: persisted.diff } });
+writer.write({ event: "analysis:ready", data: { report: persisted.report } });
+writer.write({ event: "done", data: {} });
+```
+
+On persistence conflict write `error(code: identity_conflict)` then `done`; on
+other persistence errors write `error(code: persist_failed)` then `done`. Do not
+write profile/diff/analysis first. Preserve abort/deadline cleanup and close the
+writer exactly once in `finally`.
+
+- [ ] **Step 7: Run route tests and typecheck**
+
+Run:
+
+```bash
+npm test -- tests/unit/research-cache-route.test.ts tests/unit/research-route-observability.test.ts
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 8: Commit route orchestration**
+
+```bash
+git add src/app/api/research/route.ts tests/unit/research-cache-route.test.ts tests/unit/research-route-observability.test.ts
+git commit -m "feat(api): serve research through cache"
+```
+
+### Task 4: Preserve end-to-end SSE behavior
+
+**Files:**
+
+- Modify: `tests/e2e/workflow-e2e.test.ts`
+- Modify only if tests require it: `src/lib/stream.ts`
+
+**Interfaces:** Existing SSE serialization plus new explicit cache/error events.
+
+- [ ] **Step 1: Update E2E requests and assertions**
+
+Send nested `{ input }` request bodies. Add one seeded cache hit and one miss.
+Assert both terminate with exactly one `done`; hit has no source progress and
+miss retains research progress before final events.
+
+- [ ] **Step 2: Add fatal terminal-path assertions**
+
+For post-pipeline conflict and persistence failure, assert:
+
+```ts
+expect(events.at(-2)).toMatchObject({ event: "error" });
+expect(events.at(-1)).toEqual({ event: "done", data: {} });
+expect(events.some(({ event }) => event === "profile:ready")).toBe(false);
+```
+
+- [ ] **Step 3: Run E2E and full focused server suites**
+
+Run:
+
+```bash
+npm test -- tests/e2e/workflow-e2e.test.ts \
+ tests/unit/research-cache-route.test.ts \
+ tests/integration/research-workflow.test.ts
+```
+
+Expected: PASS. Change `src/lib/stream.ts` only if close-once or event-order
+behavior cannot be expressed with its current interface.
+
+- [ ] **Step 4: Commit E2E contract**
+
+```bash
+git add tests/e2e/workflow-e2e.test.ts
+git diff -- src/lib/stream.ts
+git commit -m "test(api): cover cache sse outcomes"
+```
+
+If `src/lib/stream.ts` changed, include it in `git add`; otherwise leave it
+untouched.
+
+## Sprint 03 review gate
+
+Run:
+
+```bash
+npm test -- tests/unit/research-cache.test.ts \
+ tests/unit/research-cache-route.test.ts \
+ tests/unit/research-route-observability.test.ts \
+ tests/integration/research-workflow.test.ts \
+ tests/e2e/workflow-e2e.test.ts
+npm run lint
+npm run typecheck
+npm run build
+git status --short
+```
+
+Expected: cache hit never touches paid factories; all HTTP/SSE failure paths are
+terminal; miss/refresh persist only complete snapshots; worktree is clean before
+Sprint 04.
diff --git a/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-04-client-suggestions-and-refresh.md b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-04-client-suggestions-and-refresh.md
new file mode 100644
index 0000000..aa7456c
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-04-client-suggestions-and-refresh.md
@@ -0,0 +1,460 @@
+# Sprint 04 — Client Suggestions and Refresh Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let users safely confirm name/domain cache suggestions, see cache
+freshness, reject incorrect suggestions, and explicitly refresh a cached
+company.
+
+**Architecture:** Keep network and SSE state in the existing `useResearch`
+client hook. Add one inline suggestion component and a pure exported reducer so
+state transitions are testable under the existing Node Vitest environment
+without installing a DOM test framework.
+
+**Tech Stack:** React 19.2.8 Client Components, Next.js 16.3.2 App Router,
+TypeScript, existing Tailwind CSS styles, Vitest 4.1.11.
+
+**Spec:** `docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md`
+
+## Global Constraints
+
+- Do not use localStorage, browser cache APIs, or client-side Supabase.
+- Never decide whether a selected company is valid in the browser; the server
+ remains authoritative.
+- Use one inline accessible panel, not a modal dependency.
+- Display official name, available tax ID/domain, and last synchronized time.
+- Make “Không phải các công ty trên” and “Cập nhật lại” explicit actions.
+- A recoverable `cache_invalid` is a notice while fresh research continues; it
+ is not a terminal error.
+- Fatal errors and every `done` event must leave the UI out of loading state.
+- Preserve existing research progress and profile rendering.
+- Do not redesign unrelated page/header/form/profile styles.
+- Stage only files named by each task.
+
+---
+
+## File map
+
+| File | Action | Responsibility |
+|---|---|---|
+| `src/app/hooks/use-research.ts` | Modify | Cache-aware request builder, reducer, actions, and SSE state |
+| `src/app/components/cache-suggestions.tsx` | Create | Accessible inline candidate selection |
+| `src/app/page.tsx` | Modify | Render suggestions, freshness, bypass, and refresh controls |
+| `tests/unit/use-research-state.test.ts` | Create | Pure request/reducer state-transition tests |
+
+### Task 1: Make cache request and SSE transitions pure and testable
+
+**Files:**
+
+- Modify: `src/app/hooks/use-research.ts`
+- Create: `tests/unit/use-research-state.test.ts`
+
+**Interfaces:**
+
+```ts
+export interface CacheState {
+ hit: boolean;
+ matchedBy: CacheHitMatchedBy | null;
+ lastSyncedAt: string | null;
+ suggestions: CacheSuggestion[];
+}
+
+export interface ResearchState {
+ status: "idle" | "researching" | "choosing" | "building" | "done" | "error";
+ // existing fields remain
+ cache: CacheState;
+ notice: string | null;
+}
+
+export function buildResearchRequest(
+ input: CompanyInput,
+ cache?: ResearchRequest["cache"],
+): ResearchRequest;
+
+export function reduceResearchEvent(
+ state: ResearchState,
+ event: StreamEvent,
+): ResearchState;
+```
+
+- [ ] **Step 1: Write failing request-builder tests**
+
+```ts
+it("builds default, selected, bypass, and refresh requests", () => {
+ const input = { name: "FPT" };
+
+ expect(buildResearchRequest(input)).toEqual({ input });
+ expect(buildResearchRequest(input, { action: "select", companyId: "fpt" }))
+ .toEqual({ input, cache: { action: "select", companyId: "fpt" } });
+ expect(buildResearchRequest(input, { action: "bypass" }))
+ .toEqual({ input, cache: { action: "bypass" } });
+ expect(buildResearchRequest(input, { action: "refresh", companyId: "fpt" }))
+ .toEqual({ input, cache: { action: "refresh", companyId: "fpt" } });
+});
+```
+
+- [ ] **Step 2: Write failing cache-event reducer tests**
+
+```ts
+it("enters choosing state when suggestions arrive and stays there on done", () => {
+ const suggested = reduceResearchEvent(researchingState, {
+ event: "cache:suggestions",
+ data: { suggestions: [suggestion] },
+ });
+ const finished = reduceResearchEvent(suggested, { event: "done", data: {} });
+
+ expect(finished.status).toBe("choosing");
+ expect(finished.cache.suggestions).toEqual([suggestion]);
+});
+
+it("records cache metadata before applying cached final results", () => {
+ const next = reduceResearchEvent(researchingState, {
+ event: "cache:hit",
+ data: {
+ companyId: "fpt",
+ matchedBy: "tax_id",
+ version: 2,
+ lastSyncedAt: "2026-08-26T08:00:00.000Z",
+ },
+ });
+
+ expect(next.cache).toMatchObject({
+ hit: true,
+ matchedBy: "tax_id",
+ lastSyncedAt: "2026-08-26T08:00:00.000Z",
+ });
+});
+
+it("treats cache_invalid as recoverable and persist_failed as terminal", () => {
+ const recoverable = reduceResearchEvent(researchingState, {
+ event: "error",
+ data: { code: "cache_invalid", message: "Cache không hợp lệ" },
+ });
+ expect(recoverable.status).toBe("researching");
+ expect(recoverable.notice).toBe("Cache không hợp lệ");
+
+ const fatal = reduceResearchEvent(researchingState, {
+ event: "error",
+ data: { code: "persist_failed", message: "Không thể lưu kết quả" },
+ });
+ expect(fatal.status).toBe("error");
+});
+```
+
+- [ ] **Step 3: Run and verify exports are missing**
+
+Run: `npm test -- tests/unit/use-research-state.test.ts`
+
+Expected: FAIL because the request builder/reducer/cache state do not exist.
+
+- [ ] **Step 4: Implement the pure builder and reducer**
+
+Move the current `handleSSEEvent` switch into `reduceResearchEvent`. Keep all
+existing progress/finding/profile/diff/report behavior and add:
+
+- `cache:hit` → set cache metadata.
+- `cache:suggestions` → clear result fields, store suggestions, set `choosing`.
+- `error(cache_invalid)` → set `notice`, keep active research state.
+- Other `error` codes → set terminal error status/message.
+- `done` with suggestions → `choosing`.
+- `done` with profile → `done`.
+- Other `done` with fatal error → `error`.
+
+`handleSSEEvent` becomes one `setState(prev => reduceResearchEvent(prev,
+event))` call. Keep malformed JSON handling unchanged.
+
+- [ ] **Step 5: Run focused state tests and typecheck**
+
+Run:
+
+```bash
+npm test -- tests/unit/use-research-state.test.ts
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit state behavior**
+
+```bash
+git add src/app/hooks/use-research.ts tests/unit/use-research-state.test.ts
+git commit -m "feat(ui): handle cache research states"
+```
+
+### Task 2: Expose safe select, bypass, and refresh hook actions
+
+**Files:**
+
+- Modify: `src/app/hooks/use-research.ts`
+- Modify: `tests/unit/use-research-state.test.ts`
+
+**Interfaces:**
+
+```ts
+export interface UseResearchResult {
+ state: ResearchState;
+ research(input: CompanyInput): Promise;
+ selectSuggestion(companyId: string): Promise;
+ researchNewCompany(): Promise;
+ refresh(): Promise;
+ reset(): void;
+}
+```
+
+- [ ] **Step 1: Add request-action tests**
+
+Test the action-to-request mapping through `buildResearchRequest`:
+
+- `selectSuggestion("company-a")` uses current `state.input` and action
+ `select`.
+- `researchNewCompany()` uses current input and action `bypass`.
+- `refresh()` uses current input/profile ID and action `refresh`.
+- Missing input/profile produces a resolved no-op and no fetch.
+
+Do not install a hook rendering library. Keep network execution in one internal
+`runResearch(input, cacheAction?)` callback and cover the pure request builder.
+
+- [ ] **Step 2: Implement one shared network path**
+
+Refactor current `research` so it delegates to:
+
+```ts
+const runResearch = useCallback(async (
+ input: CompanyInput,
+ cache?: ResearchRequest["cache"],
+) => {
+ abortRef.current?.abort();
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ setState({
+ ...INITIAL_STATE,
+ input,
+ status: "researching",
+ });
+
+ try {
+ const response = await fetch("/api/research", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(buildResearchRequest(input, cache)),
+ signal: controller.signal,
+ });
+
+ await consumeResearchStream(response, setState);
+ } catch (error) {
+ if ((error as Error).name === "AbortError") return;
+ setState((current) => ({
+ ...current,
+ status: "error",
+ error: (error as Error).message,
+ }));
+ }
+}, []);
+```
+
+Extract the current response-status check, reader loop, and SSE parsing into
+`consumeResearchStream`; replace its state mutations with
+`setState((current) => reduceResearchEvent(current, event))`. This is a move of
+existing behavior, not a second network implementation.
+
+The public actions read current state only to construct the approved action and
+then call `runResearch`. Clear previous suggestions when select, bypass, or
+refresh starts. Keep abort behavior for repeated clicks.
+
+- [ ] **Step 3: Run state tests and typecheck**
+
+Run:
+
+```bash
+npm test -- tests/unit/use-research-state.test.ts
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Commit hook actions**
+
+```bash
+git add src/app/hooks/use-research.ts tests/unit/use-research-state.test.ts
+git commit -m "feat(ui): add cache selection actions"
+```
+
+### Task 3: Render accessible cache suggestions
+
+**Files:**
+
+- Create: `src/app/components/cache-suggestions.tsx`
+- Modify: `src/app/page.tsx`
+
+**Interfaces:**
+
+```ts
+interface CacheSuggestionsProps {
+ suggestions: CacheSuggestion[];
+ disabled: boolean;
+ onSelect(companyId: string): void;
+ onReject(): void;
+}
+```
+
+- [ ] **Step 1: Create the inline semantic panel**
+
+Use this structure without a modal or new dependency:
+
+```tsx
+
+
+
+ Chọn đúng doanh nghiệp
+
+
+ Chúng tôi tìm thấy dữ liệu đã lưu có tên tương tự.
+
+
+
+ {suggestions.map((suggestion) => (
+
+
+
+ ))}
+
+
+
+```
+
+Reuse existing focus styles or add visible `focus-visible` utilities to both
+button types. Do not remove native button semantics.
+
+- [ ] **Step 2: Wire choosing state into the page**
+
+Destructure `selectSuggestion` and `researchNewCompany` from the hook. When
+`state.status === "choosing"`, render `CacheSuggestions` in the result area
+instead of research progress/profile. Preserve the left-side form and current
+input so the user can correct identifiers instead.
+
+- [ ] **Step 3: Run lint/typecheck/build**
+
+Run:
+
+```bash
+npm run lint
+npm run typecheck
+npm run build
+```
+
+Expected: PASS with no client/server boundary violation.
+
+- [ ] **Step 4: Commit suggestions UI**
+
+```bash
+git add src/app/components/cache-suggestions.tsx src/app/page.tsx
+git commit -m "feat(ui): confirm cached companies"
+```
+
+### Task 4: Display cache freshness and manual refresh
+
+**Files:**
+
+- Modify: `src/app/page.tsx`
+- Modify: `tests/unit/use-research-state.test.ts`
+
+**Interfaces:** Uses `state.cache.lastSyncedAt`, `state.profile.id`, and the
+hook's `refresh()` action.
+
+- [ ] **Step 1: Add refresh state expectations**
+
+Extend reducer/request tests to prove refresh:
+
+- clears previous suggestions/notices;
+- sets status to `researching`;
+- sends the displayed profile ID;
+- a subsequent result replaces cache metadata and profile version.
+
+- [ ] **Step 2: Add the freshness/refresh controls above the profile**
+
+When a profile and `lastSyncedAt` exist, render:
+
+```tsx
+
+
+ Cập nhật lần cuối: {new Date(state.cache.lastSyncedAt).toLocaleString("vi-VN")}
+
+
+
+```
+
+Show `state.notice` as a neutral/warning callout, distinct from the existing
+fatal error callout.
+
+- [ ] **Step 3: Run focused and full UI compilation checks**
+
+Run:
+
+```bash
+npm test -- tests/unit/use-research-state.test.ts
+npm run lint
+npm run typecheck
+npm run build
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Verify visually**
+
+Run `npm run dev`, then use the in-app browser workflow to capture before/after
+screenshots for:
+
+1. Multiple cache suggestions.
+2. Cached profile with last-synchronized time.
+3. Refresh in progress with disabled button.
+4. Recoverable cache-invalid notice.
+
+Check keyboard focus order, readable timestamps, narrow viewport wrapping, and
+that selecting/rejecting suggestions reaches the intended server request.
+
+- [ ] **Step 5: Commit freshness UI**
+
+```bash
+git add src/app/page.tsx tests/unit/use-research-state.test.ts
+git commit -m "feat(ui): show cache freshness and refresh"
+```
+
+## Sprint 04 review gate
+
+Run:
+
+```bash
+npm test -- tests/unit/use-research-state.test.ts \
+ tests/unit/research-cache-route.test.ts \
+ tests/e2e/workflow-e2e.test.ts
+npm run lint
+npm run typecheck
+npm run build
+git status --short
+```
+
+Expected: state/action/server contract tests pass, all four UI states are
+visually verified, accessibility basics work by keyboard, and the worktree is
+clean before Sprint 05.
diff --git a/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-05-telemetry-and-release-hardening.md b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-05-telemetry-and-release-hardening.md
new file mode 100644
index 0000000..83aba48
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-26-supabase-research-cache-sprints/sprint-05-telemetry-and-release-hardening.md
@@ -0,0 +1,402 @@
+# Sprint 05 — Telemetry and Release Hardening Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make cache outcomes observable without leaking identifiers, close the
+remaining negative-path coverage, verify database permissions/concurrency, and
+prepare the complete feature for release.
+
+**Architecture:** Extend the existing Langfuse wrapper with one cache-outcome
+metadata function and one HMAC fingerprint helper. Keep route/storage behavior
+unchanged except for telemetry calls; finish with security, database, UI, and
+full repository verification.
+
+**Tech Stack:** Node.js `crypto`, Langfuse JS/TS 5.10.1,
+`@langfuse/tracing` 5.10.1, Vitest 4.1.11, Supabase CLI 2.115.0,
+Next.js 16.3.2.
+
+**Spec:** `docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md`
+
+## Global Constraints
+
+- Use HMAC-SHA256 with a dedicated server secret; plain SHA-256 is forbidden
+ for low-entropy tax IDs.
+- Never log raw tax IDs, domains, credentials, or request bodies.
+- Missing HMAC secret omits the fingerprint and does not fail research.
+- Reuse Langfuse; do not add a logger, metrics SDK, or telemetry dependency.
+- Cache telemetry must also exist for hits/suggestions that never create paid
+ providers.
+- Do not change cache lookup/persistence semantics while adding telemetry.
+- Verify anon/authenticated cannot call cache RPCs or access cache tables.
+- Refresh rate limiting remains explicitly deferred.
+- Stage only files named by each task.
+
+---
+
+## File map
+
+| File | Action | Responsibility |
+|---|---|---|
+| `.env.example` | Modify | Dedicated HMAC secret documentation |
+| `src/observability/langfuse.ts` | Modify | HMAC fingerprint and cache outcome metadata |
+| `src/app/api/research/route.ts` | Modify | Record lookup outcomes across every route branch |
+| `tests/unit/langfuse-observability.test.ts` | Modify | Fingerprint and cache metadata tests |
+| `tests/unit/research-cache-route.test.ts` | Modify | Final negative-path and telemetry matrix |
+| `tests/integration/supabase-cache-concurrency.test.ts` | Modify | Data API grants/RLS and rollback verification |
+| `README.md` | Modify | Cache behavior, configuration, and operational caveats |
+| `docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md` | Read only | Acceptance checklist source |
+
+### Task 1: Add non-reversible cache-key fingerprints
+
+**Files:**
+
+- Modify: `.env.example`
+- Modify: `src/observability/langfuse.ts`
+- Modify: `tests/unit/langfuse-observability.test.ts`
+
+**Interfaces:**
+
+```ts
+export function fingerprintCacheKey(
+ keyType: "tax_id" | "domain",
+ value: string,
+ secret?: string,
+): string | undefined;
+
+export interface ResearchCacheTelemetry {
+ cacheOutcome:
+ | "hit"
+ | "miss"
+ | "suggestions"
+ | "refresh"
+ | "bypass"
+ | "conflict"
+ | "invalid";
+ matchedBy?: "tax_id" | "domain" | "normalized_name" | "selected";
+ companyId?: string;
+ version?: number;
+ lastSyncedAt?: string;
+ lookupDurationMs: number;
+ conflictingCompanyIds?: string[];
+ keyType?: "tax_id" | "domain";
+ keyFingerprint?: string;
+}
+
+export function updateResearchCacheOutcome(
+ telemetry: ResearchCacheTelemetry,
+): void;
+```
+
+- [ ] **Step 1: Write failing HMAC tests**
+
+```ts
+it("fingerprints low-entropy tax IDs with a keyed HMAC", () => {
+ const first = fingerprintCacheKey("tax_id", "0101248141", "secret-a");
+ const second = fingerprintCacheKey("tax_id", "0101248141", "secret-b");
+
+ expect(first).toMatch(/^[a-f0-9]{64}$/);
+ expect(first).not.toContain("0101248141");
+ expect(second).not.toBe(first);
+ expect(fingerprintCacheKey("tax_id", "0101248141", undefined)).toBeUndefined();
+});
+
+it("separates key types in the authenticated message", () => {
+ expect(fingerprintCacheKey("tax_id", "example.vn", "secret"))
+ .not.toBe(fingerprintCacheKey("domain", "example.vn", "secret"));
+});
+```
+
+- [ ] **Step 2: Run and verify the helper is missing**
+
+Run: `npm test -- tests/unit/langfuse-observability.test.ts`
+
+Expected: FAIL because `fingerprintCacheKey` is not exported.
+
+- [ ] **Step 3: Implement HMAC with Node's standard library**
+
+```ts
+import { createHmac } from "node:crypto";
+
+export function fingerprintCacheKey(
+ keyType: "tax_id" | "domain",
+ value: string,
+ secret = process.env.CACHE_TELEMETRY_HMAC_SECRET,
+): string | undefined {
+ if (!secret) return undefined;
+ return createHmac("sha256", secret)
+ .update(`${keyType}\0${value}`)
+ .digest("hex");
+}
+```
+
+Document in `.env.example`:
+
+```dotenv
+# Server-only HMAC secret for cache-key telemetry fingerprints
+CACHE_TELEMETRY_HMAC_SECRET=replace-with-random-server-secret
+```
+
+Do not prefix it with `NEXT_PUBLIC_` and do not reuse a Supabase/Langfuse key.
+
+- [ ] **Step 4: Run focused tests and typecheck**
+
+Run:
+
+```bash
+npm test -- tests/unit/langfuse-observability.test.ts
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit the fingerprint helper**
+
+```bash
+git add .env.example src/observability/langfuse.ts tests/unit/langfuse-observability.test.ts
+git commit -m "feat(observability): protect cache identifiers"
+```
+
+### Task 2: Record every cache outcome in the existing trace
+
+**Files:**
+
+- Modify: `src/observability/langfuse.ts`
+- Modify: `src/app/api/research/route.ts`
+- Modify: `tests/unit/langfuse-observability.test.ts`
+- Modify: `tests/unit/research-cache-route.test.ts`
+
+**Interfaces:** `updateResearchCacheOutcome(telemetry)` updates active Langfuse
+observation metadata and is a no-op when Langfuse is disabled.
+
+- [ ] **Step 1: Write failing metadata tests**
+
+Mock `updateActiveObservation` and assert:
+
+```ts
+updateResearchCacheOutcome({
+ cacheOutcome: "hit",
+ matchedBy: "tax_id",
+ companyId: "company-a",
+ version: 3,
+ lastSyncedAt: "2026-08-26T08:00:00.000Z",
+ lookupDurationMs: 12,
+});
+
+expect(updateActiveObservation).toHaveBeenCalledWith({
+ metadata: expect.objectContaining({
+ cacheOutcome: "hit",
+ matchedBy: "tax_id",
+ companyId: "company-a",
+ cacheVersion: 3,
+ cacheLookupDurationMs: 12,
+ }),
+});
+```
+
+Add a conflict test proving metadata contains only company IDs, key type, and
+HMAC fingerprint—not the raw key.
+
+- [ ] **Step 2: Implement metadata-only updates**
+
+Call:
+
+```ts
+updateActiveObservation({
+ metadata: {
+ cacheOutcome: telemetry.cacheOutcome,
+ matchedBy: telemetry.matchedBy,
+ companyId: telemetry.companyId,
+ cacheVersion: telemetry.version,
+ cacheLastSyncedAt: telemetry.lastSyncedAt,
+ cacheLookupDurationMs: telemetry.lookupDurationMs,
+ conflictingCompanyIds: telemetry.conflictingCompanyIds,
+ cacheKeyType: telemetry.keyType,
+ cacheKeyFingerprint: telemetry.keyFingerprint,
+ },
+});
+```
+
+Reuse the existing Langfuse enabled check. Do not put cache metadata into
+`output`, because workflow outcome updates already own that field.
+
+- [ ] **Step 3: Start the trace before cache lookup and instrument branches**
+
+Allow `ResearchTraceContext.companyId` to be optional at trace start. Wrap cache
+lookup in the existing `traceResearch` scope, measure duration with
+`performance.now()`, then call `updateResearchCacheOutcome` for hit, miss,
+suggestions, select, refresh, bypass, conflict, and corrupt-cache recovery.
+
+Create LangChain/LangGraph callbacks only in the miss/refresh pipeline branch
+after the company ID is resolved. A hit still gets one research trace without
+provider spans.
+
+- [ ] **Step 4: Add route telemetry assertions**
+
+For hit, suggestions, miss, refresh, conflict, and invalid cache, assert exactly
+one call containing the expected `cacheOutcome`. For a conflict, assert the
+mock received an HMAC fingerprint and never received the raw test tax ID/domain.
+
+- [ ] **Step 5: Run observability/route suites**
+
+Run:
+
+```bash
+npm test -- tests/unit/langfuse-observability.test.ts \
+ tests/unit/research-cache-route.test.ts \
+ tests/unit/research-route-observability.test.ts
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit cache telemetry**
+
+```bash
+git add src/observability/langfuse.ts src/app/api/research/route.ts \
+ tests/unit/langfuse-observability.test.ts tests/unit/research-cache-route.test.ts
+git commit -m "feat(observability): trace cache outcomes"
+```
+
+### Task 3: Close the security/error regression matrix
+
+**Files:**
+
+- Modify: `tests/unit/research-cache-route.test.ts`
+- Modify: `tests/integration/supabase-cache-concurrency.test.ts`
+
+**Interfaces:** No production interface change; this task proves approved
+negative-path contracts.
+
+- [ ] **Step 1: Verify route tests contain all named regressions**
+
+Add any missing test so the suite explicitly contains these behaviors:
+
+1. `503 cache_unavailable` and zero paid-provider factory calls.
+2. Corrupt JSONB emits `cache_invalid`, then runs/persists fresh research.
+3. `select` rejects an existing unrelated company ID.
+4. `refresh` rejects an existing unrelated company ID.
+5. Pre-stream strong-key conflict returns `409`.
+6. Post-pipeline conflict emits `error(identity_conflict)` then `done`, with no
+ final profile/diff/analysis events.
+7. Persistence failure emits `error(persist_failed)` then `done`, with no final
+ profile/diff/analysis events.
+8. Every successful hit/miss/suggestion path emits exactly one `done`.
+
+Use provider-construction spies in cases 1, 3, 4, and 5.
+
+- [ ] **Step 2: Verify Data API permissions with anon and service clients**
+
+In the local Supabase integration suite, create separate anon and service-role
+clients. Assert anon cannot select/insert/update cache tables and cannot execute
+the three cache RPCs. Assert the service client can execute the intended RPCs.
+Check PostgREST error codes rather than matching English error text.
+
+- [ ] **Step 3: Rerun concurrency and rollback checks**
+
+Run two independent service clients concurrently as defined in Sprint 02. Also
+force a post-pipeline tax-ID conflict inside `persist_research_snapshot` and
+assert profile, report, diff, and identity update all roll back.
+
+- [ ] **Step 4: Run focused security gates**
+
+Run:
+
+```bash
+npm test -- tests/unit/research-cache-route.test.ts
+eval "$(npx supabase status -o env)"
+SUPABASE_TEST_URL="$API_URL" SUPABASE_TEST_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" \
+SUPABASE_TEST_ANON_KEY="$ANON_KEY" npm run test:db
+npx supabase db advisors --local
+```
+
+Expected: all regression, permission, concurrency, rollback, and advisor checks
+pass.
+
+- [ ] **Step 5: Commit the hardening tests**
+
+```bash
+git add tests/unit/research-cache-route.test.ts tests/integration/supabase-cache-concurrency.test.ts
+git commit -m "test(cache): cover security failure paths"
+```
+
+### Task 4: Document operations and execute release verification
+
+**Files:**
+
+- Modify: `README.md`
+
+**Interfaces:** Documents supported configuration and operator-visible cache
+behavior; no runtime interface change.
+
+- [ ] **Step 1: Document cache configuration and behavior**
+
+Add concise README sections covering:
+
+- Required `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, and optional
+ `CACHE_TELEMETRY_HMAC_SECRET`.
+- Cache hit/miss/suggestions/manual refresh behavior.
+- Tax ID/domain/name matching safety rules.
+- Local Supabase start/reset/database-test commands.
+- Cache entries have no TTL.
+- Identity merge, first-miss stampede prevention, partial pipeline resume, and
+ refresh rate limiting are not implemented.
+- Raw service-role/HMAC secrets are server-only.
+
+- [ ] **Step 2: Run the complete repository gate**
+
+Run:
+
+```bash
+npm test
+npm run lint
+npm run typecheck
+npm run build
+npx supabase migration list --local
+npx supabase db advisors --local
+eval "$(npx supabase status -o env)"
+SUPABASE_TEST_URL="$API_URL" SUPABASE_TEST_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" \
+SUPABASE_TEST_ANON_KEY="$ANON_KEY" npm run test:db
+git diff --check
+```
+
+Expected: every command passes with no warning attributable to the cache
+feature.
+
+- [ ] **Step 3: Repeat visual acceptance checks**
+
+Use the local app and local Supabase to verify:
+
+1. First query miss persists a complete snapshot.
+2. Second safe query hit performs no provider call and displays freshness.
+3. Name-only query shows suggestions and requires confirmation.
+4. Rejecting suggestions starts new research.
+5. Refresh creates the next version/diff.
+6. Invalid cache recovers visibly; fatal persistence stops cleanly.
+
+Capture final screenshots and compare them with Sprint 04 screenshots for
+unexpected layout regressions.
+
+- [ ] **Step 4: Commit operational documentation**
+
+```bash
+git add README.md
+git commit -m "docs(cache): document operations and limits"
+```
+
+## Sprint 05 release gate
+
+The feature is ready for branch integration only when:
+
+- all Sprint 05 commands pass;
+- Supabase advisors are clean;
+- anon/authenticated direct access is denied;
+- service-role access is server-only;
+- HMAC telemetry contains no raw keys;
+- hit/miss/suggestion/refresh/error traces are observable;
+- all SSE paths terminate;
+- final UI screenshots are reviewed;
+- `git status --short` is empty.
+
+Skipped by design: refresh rate limiting, full first-miss stampede prevention,
+TTL, automatic identity merge, and partial pipeline resume. Add them only in a
+separate approved spec when production evidence justifies the extra machinery.
diff --git a/docs/superpowers/plans/2026-08-29-native-research-workflow.md b/docs/superpowers/plans/2026-08-29-native-research-workflow.md
new file mode 100644
index 0000000..01b0610
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-29-native-research-workflow.md
@@ -0,0 +1,96 @@
+# Native Research Workflow Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace LangGraph/LangChain with native TypeScript orchestration and the OpenAI SDK without changing research behavior.
+
+**Architecture:** One native executor owns state and emits the existing `StreamEvent` union through a callback. Source runners execute through a bounded worker pool backed by `Promise.allSettled()`; `run()` uses a no-op emitter and `stream()` bridges the same executor to a small async queue. OpenAI structured responses use `responses.parse()` with the caller's Zod schema while existing Langfuse observations remain active around workflow steps.
+
+**Tech Stack:** TypeScript, OpenAI SDK, Zod, Vitest, Langfuse tracing, OpenTelemetry
+
+**Spec:** `docs/superpowers/specs/2026-08-29-native-research-workflow-design.md`
+
+## Global Constraints
+
+- Preserve `ResearchWorkflow.run()` and `ResearchWorkflow.stream()` public behavior.
+- Preserve SSE payloads, abort propagation, retry/timeout rules, budgets, RRF/evidence, cache, storage, export, and UI behavior.
+- Keep Langfuse manual observations, scores, masking, OTel startup, and flush.
+- Remove `@langchain/langgraph`, `@langchain/core`, `@langchain/openai`, and `@langfuse/langchain` only.
+- Do not add a replacement framework, event bus, agent loop, checkpoint store, or queue dependency.
+
+---
+
+### Task 1: Native OpenAI structured adapter
+
+**Files:**
+- Modify: `src/adapters/llm/types.ts`
+- Modify: `src/adapters/llm/openai.ts`
+- Modify: `src/adapters/llm/index.ts`
+- Modify: `tests/helpers/mock-adapters.ts`
+- Delete: `tests/unit/langchain-llm.test.ts`
+- Create: `tests/unit/openai-llm.test.ts`
+
+**Interfaces:**
+- Produces: `LLMAdapter.completeStructured(prompt, schema, options): Promise`.
+- Produces: `LLMOptions.schemaName?: string` and `LLMInvocationContext` containing only `signal` and `budget`.
+
+- [ ] Write native-client tests proving Zod parsing, system/user input, abort forwarding, estimated-budget claim, actual usage recording, and null parsed-output rejection.
+- [ ] Run `npm test -- tests/unit/openai-llm.test.ts` and confirm it fails because the adapter still expects LangChain.
+- [ ] Replace the model factory with an injected minimal OpenAI client exposing `responses.parse()`; call `zodTextFormat(schema, schemaName)` and record `input_tokens`, `output_tokens`, and `total_tokens`.
+- [ ] Remove unused free-text completion, model streaming, callback context, and usage-log storage from the interface, adapter, and mock.
+- [ ] Run adapter, Profile, Analyst, and budget tests until green.
+
+### Task 2: Native concurrent workflow executor
+
+**Files:**
+- Modify: `src/modules/workflow/state.ts`
+- Modify: `src/modules/workflow/index.ts`
+- Delete: `tests/unit/langgraph-runtime.test.ts`
+- Create: `tests/unit/native-workflow-runtime.test.ts`
+- Modify: `tests/integration/research-workflow.test.ts`
+
+**Interfaces:**
+- Consumes: unchanged source runners and `ResearchBudget`.
+- Produces: one `executeWorkflow(input, options, deps, runners, emit)` path used by both `run()` and `stream()`.
+
+- [ ] Add tests with controlled source promises proving overlap, `maxConcurrentSourceNodes`, early finding delivery, partial success after one rejection, abort propagation, exactly-once completion, and equivalent `run()`/`stream()` final state.
+- [ ] Run the new workflow tests and confirm they fail against the graph implementation.
+- [ ] Convert `ResearchWorkflowState` to a plain interface and delete `Annotation` state/reducers.
+- [ ] Implement a minimal bounded mapper that submits active source jobs, collects results via `Promise.allSettled()`, and preserves the skipped LinkedIn result.
+- [ ] Replace `dispatchCustomEvent()` with an injected async emitter; preserve the existing retry, timeout, query-budget, source-error, evidence, profile, diff, and analyst logic.
+- [ ] Implement a local async event queue for `stream()` and a no-op emitter for `run()`; propagate executor errors once and close once.
+- [ ] Run workflow unit/integration/e2e tests until green.
+
+### Task 3: Keep Langfuse without LangChain callbacks
+
+**Files:**
+- Modify: `src/observability/langfuse.ts`
+- Modify: `src/app/api/research/route.ts`
+- Modify: `tests/unit/langfuse-observability.test.ts`
+- Modify: `tests/unit/research-route-observability.test.ts`
+- Modify: `tests/unit/research-cache-route.test.ts`
+
+**Interfaces:**
+- Consumes: existing `traceResearch()`, `observeResearchStep()`, scores, masking, and flush functions.
+- Produces: route calls workflow without a `callbacks` option.
+
+- [ ] Update tests to remove `createLangfuseCallback()` mocks/assertions while retaining observation, masking, score, failure, and flush coverage.
+- [ ] Run observability and route tests and confirm they fail while callback plumbing remains.
+- [ ] Delete the `CallbackHandler` import and `createLangfuseCallback()` function.
+- [ ] Remove callback creation and forwarding from the research route; leave the manual root/source/Profile/Analyst observation flow unchanged.
+- [ ] Run observability, cache-route, and research-route tests until green.
+
+### Task 4: Dependency cleanup and release verification
+
+**Files:**
+- Modify: `package.json`
+- Modify: `package-lock.json`
+- Modify: any test names/descriptions that still claim LangGraph/LangChain behavior
+
+**Interfaces:**
+- Produces: dependency tree with native `openai`, Zod, Langfuse, and OTel only.
+
+- [ ] Run `npm uninstall @langchain/langgraph @langchain/core @langchain/openai @langfuse/langchain`.
+- [ ] Run `rg -n '@langchain|@langfuse/langchain|createLangfuseCallback|callbacks:' src tests package.json package-lock.json` and require no production matches.
+- [ ] Run `npm test`, `npm run typecheck`, `npm run lint`, and `npm run build`.
+- [ ] Run `git diff --check` and review the diff against every acceptance criterion in the spec.
diff --git a/docs/superpowers/specs/2026-08-25-partneriq-langgraph-langfuse-design.md b/docs/superpowers/specs/2026-08-25-partneriq-langgraph-langfuse-design.md
index c8833a0..eabc05d 100644
--- a/docs/superpowers/specs/2026-08-25-partneriq-langgraph-langfuse-design.md
+++ b/docs/superpowers/specs/2026-08-25-partneriq-langgraph-langfuse-design.md
@@ -105,9 +105,10 @@ interface ResearchWorkflowState {
}
```
-`sourceResults` and `findings` use append reducers because parallel nodes may
-update them in any completion order. Downstream code never consumes reducer
-order directly; `prepare_evidence` produces deterministic order first.
+`sourceResults` uses an append reducer because parallel nodes may update it in
+any completion order. Source nodes do not also write `findings`; that would
+duplicate the same evidence. `prepare_evidence` derives and overwrites the
+single deterministic `findings` array consumed downstream.
## Graph nodes and edges
@@ -214,8 +215,9 @@ and run behind a feature flag. `Send` is introduced only with that feature.
## Resource and failure policy
-- One global run deadline is lower than the configured Vercel `maxDuration` so
- the graph can emit a terminal SSE event and flush Langfuse before termination.
+- Configure Vercel `maxDuration = 300` seconds and enforce an internal
+ 285-second run deadline so the graph retains 15 seconds to emit a terminal
+ SSE event, close the writer, and flush Langfuse.
- Each source has an explicit timeout and a provider concurrency limit.
- Retry only transient timeout, 429, 5xx, and network-reset failures.
- Authentication, invalid URL, blocked target, schema, and empty-result errors
@@ -294,10 +296,10 @@ workflow is considered complete. It is not shut down per request.
## Vercel and SSE behavior
- The route explicitly uses the Node.js runtime.
-- `maxDuration` is configured in the route and must stay within the active
- Vercel plan.
-- The internal run deadline reserves time for `done`/`error`, writer close, and
- Langfuse flush.
+- The route exports `maxDuration = 300`, which stays within the current Vercel
+ Fluid Compute maximum across plans.
+- The internal run deadline is 285 seconds and reserves 15 seconds for
+ `done`/`error`, writer close, and Langfuse flush.
- The graph stream is consumed for the lifetime of the SSE response; no detached
background queue is introduced.
- The writer closes exactly once on success, fatal error, or abort.
diff --git a/docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md b/docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md
new file mode 100644
index 0000000..f95bc59
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-26-supabase-research-cache-design.md
@@ -0,0 +1,534 @@
+# Supabase Research Cache Design
+
+**Date:** 2026-08-26
+
+**Status:** Draft for final review
+
+**Scope:** Shared, non-expiring research cache backed by Supabase
+
+## 1. Goal
+
+Return previously completed company research without running Serper, scraping,
+profile synthesis, or analysis again. When no safe cache match exists, run the
+current research pipeline and persist a complete snapshot for later requests.
+
+Cache entries do not expire. A user refreshes a cached company explicitly when
+new research is required.
+
+## 2. Current Context
+
+The research endpoint streams a LangGraph workflow over SSE. The workflow
+currently loads and writes versioned `CompanyProfile` and `ProfileDiff` records
+through `StorageAdapter`, but it still executes all source and LLM work before
+loading the previous profile. Supabase stores profiles and diffs; it does not
+store `AnalysisReport`. Company identity currently falls back to
+`slugify(input.name)`, which is not safe as a canonical identifier.
+
+Relevant code:
+
+- `src/app/api/research/route.ts`
+- `src/app/hooks/use-research.ts`
+- `src/app/page.tsx`
+- `src/modules/workflow/index.ts`
+- `src/adapters/storage/types.ts`
+- `src/adapters/storage/supabase.ts`
+- `src/adapters/storage/memory.ts`
+- `src/config/index.ts`
+- `src/lib/types.ts`
+- `src/observability/langfuse.ts`
+
+## 3. Architectural Decision
+
+Use a read-through flow at the research route with a dedicated research-cache
+module. The cache module is the seam between request identity, Supabase lookup,
+and complete research snapshots.
+
+The route performs cache resolution before constructing the LLM, search,
+scraper, registry, profile, analyst, or workflow modules. A cache hit therefore
+does not require provider credentials and cannot accidentally call a paid
+provider.
+
+The cache module exposes the minimum interface needed by callers:
+
+- Resolve an input into a hit, suggestions, a miss, or an identity conflict.
+- Resolve or create the canonical identity used by a pipeline run.
+- Persist one complete profile/report/diff snapshot.
+
+Supabase remains the persistent shared store. No browser `localStorage` cache
+and no additional in-process LRU cache are part of this change.
+
+## 4. Data Model
+
+### 4.1 `company_identities`
+
+Add one canonical identity row per known company:
+
+| Column | Type | Rules |
+|---|---|---|
+| `id` | `text` | Primary key. Existing IDs are retained; new IDs are UUID strings. |
+| `tax_id` | `text` | Nullable normalized tax ID. |
+| `normalized_domain` | `text` | Nullable normalized hostname. Not unique. |
+| `normalized_name` | `text` | Required normalized company name. Not unique. |
+| `created_at` | `timestamptz` | Creation time. |
+| `updated_at` | `timestamptz` | Last identity metadata change, not research freshness. |
+
+Required indexes and constraints:
+
+- Primary key on `id`.
+- Partial unique index on `tax_id` where `tax_id IS NOT NULL`.
+- Non-unique index on `normalized_domain`.
+- Non-unique index on `normalized_name`.
+
+`normalized_domain` is intentionally non-unique because multiple legal entities
+may use one corporate domain. `normalized_name` is never used as an automatic
+cache hit.
+
+### 4.2 `company_profiles`
+
+Keep versioned rows keyed by `(id, version)` and add:
+
+- `analysis_report JSONB NULL`.
+- Foreign key `id → company_identities(id)` after existing data is backfilled.
+- Partial lookup index `(id, version DESC) WHERE analysis_report IS NOT NULL`.
+
+`company_profiles.updated_at` is the research synchronization time exposed to
+the client as `lastSyncedAt`. It is distinct from
+`company_identities.updated_at`.
+
+A row is cacheable only when `analysis_report IS NOT NULL`. The current analyst
+produces one structured report after a single `completeStructured` call; partial
+report streaming is not supported. Therefore a separate completion-status
+column is unnecessary.
+
+### 4.3 `company_diffs`
+
+Keep the existing table and add a foreign key from `company_id` to
+`company_identities(id)`. A cached snapshot includes only the diff whose
+`to_version` equals the selected profile version; version 1 returns `null`.
+
+### 4.4 Backfill
+
+Backfill one `company_identities` row for each existing distinct profile ID.
+Derive identity fields from the latest profile version. Preserve every existing
+profile ID so existing profile/diff references remain valid. New companies use
+`crypto.randomUUID()` converted to a string; `slugify(name)` is no longer an
+identity source.
+
+## 5. Normalization Rules
+
+Normalization happens at the cache trust boundary before lookup or persistence.
+Input and pipeline-derived identity values use the same functions.
+
+### Tax ID
+
+- Trim surrounding whitespace.
+- Remove spaces, dots, and hyphens.
+- Accept only 10 or 13 decimal digits after normalization.
+- Return `null` for an absent value; reject a present malformed value.
+
+### Domain
+
+- Parse the already URL-validated website with the platform `URL` class.
+- Use `hostname`, lowercase it, remove a trailing dot, and remove one leading
+ `www.`.
+- Ignore scheme, credentials, port, path, query, and fragment.
+- Return `null` when no website is supplied.
+
+### Company name
+
+- Apply Unicode NFKC normalization.
+- Trim, lowercase using the Vietnamese locale, and collapse consecutive
+ whitespace.
+- Retain Vietnamese diacritics, punctuation, and legal suffixes such as `TNHH`
+ and `CP`.
+- Never use a name match as an automatic cache hit.
+
+## 6. Lookup Rules
+
+Lookup uses the following order:
+
+1. `taxId`
+2. normalized domain
+3. normalized name
+
+Decision rules:
+
+| Condition | Result |
+|---|---|
+| Tax ID resolves to one identity and supplied domain is compatible | Automatic hit |
+| Domain resolves to exactly one identity and no tax ID conflicts | Automatic hit |
+| Domain resolves to multiple identities | Suggestions requiring confirmation |
+| Name resolves to one or more identities | Suggestions requiring confirmation |
+| No layer resolves | Miss; run the pipeline immediately |
+| Supplied tax ID and domain resolve to different identities | `identity_conflict` |
+| Cached profile/report/diff fails runtime validation | `cache_invalid`; treat as miss |
+
+When a tax ID resolves to identity A, a supplied domain is compatible when it
+has no candidates or when every domain candidate set used for this request
+contains A. A domain candidate set that excludes A is an identity conflict. A
+multi-candidate domain without a tax ID remains a suggestion result.
+
+No automatic identity merge is permitted. Conflicts require corrected user
+input or later manual/admin resolution.
+
+## 7. Request Contract
+
+The request is a discriminated union:
+
+```typescript
+type ResearchRequest =
+ | { input: CompanyInput; cache?: undefined }
+ | {
+ input: CompanyInput;
+ cache: { action: "select"; companyId: string };
+ }
+ | {
+ input: CompanyInput;
+ cache: { action: "refresh"; companyId: string };
+ }
+ | {
+ input: CompanyInput;
+ cache: { action: "bypass" };
+ };
+```
+
+`select` validation recomputes the full suggestion candidate set from the
+supplied input, including ambiguous-domain and normalized-name candidates, and
+accepts the requested `companyId` only when it belongs to that set. Merely
+checking that the ID exists is insufficient.
+
+`refresh` resolves the supplied input through the same tax ID → domain → name
+chain. The selected `companyId` must be compatible with the result. A mismatch
+returns an explicit conflict; it never silently falls back to bypass.
+
+`bypass` is used only after a user rejects non-empty suggestions. A normal
+zero-result lookup starts the pipeline immediately without an extra client
+round trip.
+
+## 8. SSE Contract
+
+Add these events while retaining the existing profile, diff, analysis, error,
+and done events:
+
+```typescript
+type CacheHitMatchedBy = "tax_id" | "domain" | "selected";
+
+type CacheSuggestion = {
+ companyId: string;
+ officialName: string;
+ taxId?: string;
+ domain?: string;
+ lastSyncedAt: string;
+};
+
+type CacheStreamEvent =
+ | {
+ event: "cache:hit";
+ data: {
+ companyId: string;
+ matchedBy: CacheHitMatchedBy;
+ version: number;
+ lastSyncedAt: string;
+ };
+ }
+ | {
+ event: "cache:suggestions";
+ data: { suggestions: CacheSuggestion[] };
+ };
+```
+
+A cache hit streams, in order:
+
+1. `cache:hit`
+2. `profile:ready`
+3. `diff:ready`
+4. `analysis:ready`
+5. `done`
+
+Every SSE execution path terminates explicitly. A fatal streaming failure emits
+`error` and then `done`; the server never silently closes a stream it has
+started.
+
+Extend the existing error event:
+
+```typescript
+type ResearchErrorCode =
+ | "identity_conflict"
+ | "cache_invalid"
+ | "persist_failed"
+ | "research_failed";
+
+type ResearchErrorEvent = {
+ event: "error";
+ data: {
+ code?: ResearchErrorCode;
+ message: string;
+ source?: SourceName;
+ };
+};
+```
+
+`cache_invalid` is recoverable: emit the error event, continue with a cache
+miss, and finish with the pipeline's normal final events. `persist_failed` and
+post-pipeline `identity_conflict` are fatal: emit the error event followed by
+`done`, with no profile/diff/analysis final events.
+
+## 9. Server Flow
+
+### Default lookup
+
+1. Parse and validate `ResearchRequest`.
+2. Construct only the storage/cache dependencies.
+3. Normalize input and perform lookup.
+4. Return HTTP errors that occur before SSE starts.
+5. On hit, validate and stream the complete snapshot without constructing paid
+ providers.
+6. On suggestions, stream the candidates and `done`.
+7. On an empty lookup, resolve/create an identity and run the pipeline.
+
+### Pipeline miss
+
+1. Resolve or create a canonical identity.
+2. Construct LLM, search, scraper, registry, profile, analyst, and workflow
+ modules only after the miss is confirmed.
+3. Pass `companyId` and `existingProfile` into the workflow. The workflow does
+ not derive identity with `slugify(name)` and does not repeat the cache read.
+4. Stream source and build progress only; hold final profile/diff/analysis
+ events until validation and persistence succeed.
+5. Normalize the tax ID/domain/name found in the completed profile and validate
+ them against `company_identities` again.
+6. Persist identity metadata, profile, report, and matching diff atomically.
+7. Stream final profile, diff, analysis, and done events.
+
+### Refresh
+
+1. Revalidate the client-provided company ID against the current input.
+2. Load its latest complete snapshot as the previous profile.
+3. Bypass the cache response and run the pipeline under the same canonical ID.
+4. Persist the next version and its diff.
+
+### Post-pipeline conflict
+
+If newly discovered identity values belong to another company, do not merge,
+persist, or stream the completed result. Record conflict telemetry, emit
+`error.code = "identity_conflict"`, then emit `done`.
+
+## 10. Database Concurrency
+
+Use database RPCs because `supabase-js` cannot group multiple PostgREST calls
+into one client-controlled transaction.
+
+### Resolve/create identity RPC
+
+The RPC uses separate branches:
+
+- With tax ID: rely on the partial unique index and `INSERT ... ON CONFLICT`
+ behavior. Re-read and validate the winning identity; do not overwrite or
+ merge conflicting domain/name data automatically.
+- Without tax ID but with domain: acquire
+ `pg_advisory_xact_lock(hashtext(normalized_domain))`, recheck the domain/name,
+ and insert or reuse inside the same transaction.
+- Name only: create a UUID identity after suggestions have been rejected or no
+ suggestions exist. Duplicate name-only identities remain possible because a
+ name is intentionally not treated as unique.
+
+The lock is transaction-scoped and releases automatically on commit or
+rollback. A session-level advisory lock is forbidden.
+
+The identity lock prevents duplicate identity rows; it does not prevent two
+simultaneous cache misses from both running the expensive pipeline. Profile and
+diff upserts preserve database integrity if this occurs, but duplicate provider
+cost remains a documented ceiling.
+
+### Persist snapshot RPC
+
+Persist the identity metadata update, complete profile/report row, and matching
+diff in one transaction. Revalidate pipeline-derived keys in this transaction
+before writing. Any conflict or write failure rolls back the whole snapshot.
+
+## 11. Runtime Validation
+
+Validate Supabase JSONB before it crosses the cache interface. The runtime
+schema covers the complete `CompanyProfile`, `AnalysisReport`, and optional
+`ProfileDiff`, including version/company-ID agreement.
+
+An invalid snapshot:
+
+1. Emits `cache_invalid` telemetry.
+2. Emits a recoverable SSE error when the stream has started.
+3. Is treated as a miss.
+4. Is never returned to the client as a cache hit.
+
+## 12. HTTP Error Contract
+
+Errors detected before the SSE response use JSON:
+
+```typescript
+type ResearchHttpError = {
+ error: {
+ code:
+ | "invalid_request"
+ | "invalid_cache_selection"
+ | "identity_conflict"
+ | "cache_unavailable";
+ message: string;
+ };
+};
+```
+
+| Status | Code | Behavior |
+|---|---|---|
+| `400` | `invalid_request` | Malformed input or cache action. |
+| `400` | `invalid_cache_selection` | Existing company ID is not a candidate for the current input. |
+| `409` | `identity_conflict` | Strong identifiers disagree. No provider is constructed or called. |
+| `503` | `cache_unavailable` | Supabase lookup/RPC is unavailable. No provider is constructed or called. |
+
+The client handles JSON errors before opening/consuming SSE and handles SSE
+errors after a stream begins. In both cases it reaches an explicit terminal UI
+state.
+
+## 13. Telemetry
+
+Reuse the existing Langfuse integration. Do not add a logger or observability
+dependency.
+
+Record:
+
+- `cacheOutcome`: `hit`, `miss`, `suggestions`, `refresh`, `bypass`, `conflict`,
+ or `invalid`.
+- `matchedBy`: `tax_id`, `domain`, `normalized_name`, or `selected`.
+- Resolved company ID, selected profile version, last synchronization time, and
+ lookup duration.
+- On conflict: both company IDs, key type, and a keyed fingerprint. Never emit
+ the raw tax ID or domain.
+
+Use Node's built-in `crypto.createHmac("sha256", secret)` with the dedicated
+server secret `CACHE_TELEMETRY_HMAC_SECRET`. A plain SHA-256 hash is forbidden
+for tax IDs because their input space is small. Use the same HMAC helper for
+domains for consistency. If the secret is absent, omit the fingerprint rather
+than logging the raw value or failing the research request.
+
+## 14. Client Experience
+
+- A cache hit displays the existing result immediately, its last synchronized
+ time, and a visible “Cập nhật lại” action.
+- Name or ambiguous-domain suggestions show company name, tax ID when present,
+ domain, and last synchronized time.
+- Selecting a suggestion submits `action: "select"`.
+- Rejecting non-empty suggestions submits `action: "bypass"`.
+- Refresh submits `action: "refresh"` for the currently displayed company.
+- Recoverable `cache_invalid` informs the user that cached data was unusable and
+ that fresh research is running.
+- Fatal errors stop loading and show the server message.
+
+## 15. Verification Strategy
+
+Use the existing Vitest suite and its current mock-adapter patterns. Do not add
+a test framework.
+
+### Unit tests
+
+- Tax ID, domain, and name normalization.
+- Lookup priority and decision table.
+- Tax/domain conflict detection.
+- Ambiguous domain and name suggestions.
+- Complete-snapshot runtime validation.
+- Latest complete profile selection and diff `to_version` matching.
+- HMAC fingerprint determinism, secret separation, and omission without a
+ secret.
+
+### Route tests
+
+- Cache hit streams the complete event sequence and never constructs/calls LLM,
+ Serper, or scraper adapters.
+- Supabase/RPC failure returns `503 cache_unavailable` and never
+ constructs/calls paid providers.
+- A corrupt snapshot emits `cache_invalid`, becomes a miss, and runs the
+ pipeline.
+- `select` rejects an existing, valid company ID that is not in the current
+ input's suggestion set.
+- `refresh` rejects an existing, valid company ID belonging to an unrelated
+ company.
+- An empty lookup starts the pipeline without a bypass round trip.
+- A pre-stream identity conflict returns `409`.
+- A post-pipeline conflict emits `error.code = "identity_conflict"`, then
+ `done`, and emits no final profile/diff/analysis events.
+- Persist failure emits `error.code = "persist_failed"`, then `done`, and emits
+ no final profile/diff/analysis events.
+
+### Workflow tests
+
+- Workflow uses the supplied canonical company ID and previous profile.
+- Refresh creates the next version and a diff whose `toVersion` matches it.
+- Workflow no longer reads existing data via `slugify(name)`.
+
+### Database integration tests
+
+- Two genuinely separate database clients/connections invoke the domain-only
+ resolve/create RPC concurrently for the same normalized domain and name.
+- Assert both calls return the same identity and only one matching identity row
+ exists.
+- A sequential or same-connection test is insufficient because it cannot prove
+ the advisory lock works under concurrent transactions.
+- Verify tax-ID `ON CONFLICT`, transactional rollback, the partial complete-row
+ index query, and snapshot/diff foreign-key integrity.
+
+### UI verification
+
+- Verify name suggestions, rejecting suggestions, selecting a cached company,
+ last-synchronized time, recoverable cache-invalid state, and refresh.
+- Capture before/after screenshots for the changed UI states.
+
+### Final verification
+
+- Full Vitest suite.
+- Lint.
+- Typecheck.
+- Production build.
+
+## 16. Security Properties
+
+- Client-supplied company IDs are always rebound to the current normalized
+ input before read or refresh.
+- Strong-identifier conflicts fail closed and never merge identities.
+- Cached JSONB is runtime-validated before use.
+- Paid providers are not constructed on hit, invalid selection, conflict, or
+ cache-backend failure.
+- Advisory locks are transaction-scoped.
+- Telemetry never stores raw tax IDs or domains.
+- Database functions use `SECURITY INVOKER` by default, explicitly qualify
+ referenced schemas, and receive only the grants required by the server role.
+- Existing Supabase keys remain server-only; no service-role key is exposed to
+ the browser.
+
+## 17. Deferred Scope
+
+- TTL or automatic freshness invalidation.
+- In-process LRU/cache layer in front of Supabase.
+- Automatic identity merge or an admin merge interface.
+- Resuming analysis from partially persisted findings/profile data.
+- Full cache-stampede prevention for concurrent first misses.
+- Refresh rate limiting. Until a later phase adds per-user/company limits, the
+ existing global research guards remain the only cost ceiling; this risk must
+ be revisited before exposing refresh to untrusted high-volume traffic.
+
+## 18. Acceptance Criteria
+
+The design is complete when all of the following are true:
+
+1. A safe cache hit returns profile, matching diff, and analysis without
+ constructing or calling paid providers.
+2. A miss runs the existing pipeline and atomically stores a complete reusable
+ snapshot.
+3. Name matches and ambiguous domains require server-validated user selection.
+4. Refresh is explicit, server-bound to the supplied input, and creates a new
+ version/diff.
+5. Identity conflicts never auto-merge and always produce observable terminal
+ errors.
+6. Supabase failure cannot trigger an expensive uncached run.
+7. Concurrent domain-only identity creation is verified with independent
+ database connections.
+8. Every SSE path ends with `done`, including fatal errors.
+9. The UI displays cache age and offers manual refresh.
+10. Existing research, export, observability, and storage tests continue to
+ pass.
diff --git a/docs/superpowers/specs/2026-08-29-native-research-workflow-design.md b/docs/superpowers/specs/2026-08-29-native-research-workflow-design.md
new file mode 100644
index 0000000..cae814e
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-29-native-research-workflow-design.md
@@ -0,0 +1,169 @@
+# Native Research Workflow Design
+
+**Date:** 2026-08-29
+
+**Status:** Approved direction; implementation pending
+
+## Goal
+
+Replace LangGraph and LangChain with native TypeScript orchestration and the
+OpenAI SDK while preserving parallel research, SSE progress, cancellation,
+partial failure, budgets, evidence provenance, RRF ranking, and Langfuse/OTel
+observability.
+
+This design supersedes only the orchestration and model-integration decisions
+in `2026-08-25-partneriq-langgraph-langfuse-design.md`. Existing evidence,
+security, storage, cache, export, and UI behavior remains unchanged.
+
+## Decisions
+
+1. Remove `@langchain/langgraph`, `@langchain/core`, `@langchain/openai`, and
+ `@langfuse/langchain`.
+2. Keep `openai`, Zod, `@langfuse/client`, `@langfuse/tracing`,
+ `@langfuse/otel`, and `@opentelemetry/sdk-node`.
+3. Keep the public `ResearchWorkflow.run()` and `ResearchWorkflow.stream()`
+ interface so the API route and cache flow do not need a redesign.
+4. Execute active source runners concurrently with `Promise.allSettled()`.
+5. Keep the existing provider-slot guard, per-source retry, timeout, query
+ budget, model-call budget, and abort propagation.
+6. Keep the LLM seam, but reduce it to the one production operation actually
+ used: `completeStructured()`.
+7. Use `OpenAI.responses.parse()` with `zodTextFormat()` for structured output.
+8. Keep Langfuse manual workflow/source/profile/analyst observations, scores,
+ masking, OTel initialization, and flush. Remove only the LangChain callback.
+9. Do not add an agent loop, tool framework, checkpoint store, event bus
+ dependency, queue, or replacement orchestration framework.
+
+## Native workflow
+
+```text
+web_search ─┐
+website ────┤
+news ───────┼─ Promise.allSettled ─ evidence/RRF ─ profile ─ diff ─ analyst
+registry ───┤
+linkedin ───┘
+```
+
+All active source tasks are submitted before awaiting any one result. A small
+native worker pool runs at most `maxConcurrentSourceNodes` tasks at once;
+`maxConcurrentProviderCalls` continues to limit search, scraper, and registry
+calls inside those runners.
+
+Each runner emits `started`, findings, and `done` or `failed` through an
+in-process callback. `stream()` bridges that callback to its existing async
+generator with a minimal local queue. `run()` uses the same execution function
+with a no-op emitter. There is one orchestration implementation, not separate
+run and stream pipelines.
+
+`Promise.allSettled()` is required rather than `Promise.all()` so a failed
+source cannot cancel successful siblings. Source failures are converted to the
+existing `SourceExecutionResult`; evidence preparation runs after all active
+sources settle.
+
+## State and event contracts
+
+`ResearchWorkflowState` becomes a plain TypeScript interface. Delete the
+LangGraph `Annotation` schema and reducers. The native executor owns state
+updates directly and preserves these invariants:
+
+- `sourceResults` contains exactly one result per active source, plus a skipped
+ LinkedIn result when no LinkedIn URL was supplied.
+- `findings` is written once from `prepareEvidence(sourceResults)`.
+- Profile runs only when prepared findings exist.
+- Diff runs only after a profile exists.
+- Analyst failure produces a partial outcome and does not discard the profile.
+- `onComplete` runs exactly once with final state.
+- Abort stops pending waits/provider calls and does not emit a success event.
+
+The existing `StreamEvent` union remains unchanged.
+
+## Structured LLM seam
+
+```ts
+export interface LLMInvocationContext {
+ signal?: AbortSignal;
+ budget?: LLMBudget;
+}
+
+export interface LLMOptions {
+ model?: string;
+ temperature?: number;
+ maxTokens?: number;
+ systemPrompt?: string;
+ context?: LLMInvocationContext;
+ schemaName?: string;
+}
+
+export interface LLMAdapter {
+ completeStructured(
+ prompt: string,
+ schema: z.ZodSchema,
+ options?: LLMOptions,
+ ): Promise;
+}
+```
+
+`OpenAIAdapter` accepts an injected minimal OpenAI client in tests and creates
+the real SDK client in production. It builds system/user input, claims the
+existing estimated token budget before the call, passes the abort signal, and
+records actual `input_tokens`, `output_tokens`, and `total_tokens` afterward.
+Missing parsed output is an error.
+
+No generic `complete()`, model streaming method, LangChain message type,
+callback array, or usage-log getter remains because production does not use
+them.
+
+## Langfuse
+
+Keep:
+
+- `traceResearch()` root trace;
+- `observeResearchStep()` around each concurrent source and downstream step;
+- deterministic scores and cache telemetry;
+- privacy masking and hashed company identifiers;
+- OTel startup in `src/instrumentation.ts`;
+- `flushLangfuse()` at request completion.
+
+Remove `createLangfuseCallback()` and all `CallbackHandler` plumbing. Native
+OpenAI calls execute inside the existing active Profile/Analyst observation;
+no second tracing framework is introduced.
+
+## Error handling
+
+- Preserve current retry classification and per-source timeout behavior.
+- Preserve `ResearchQueryBudgetExceededError` as a skipped source result.
+- Preserve fatal profile errors and partial analyst errors.
+- Queue consumers receive the original thrown error once; completion closes
+ the queue once.
+- Langfuse initialization or export failure remains non-fatal.
+
+## Verification
+
+Migration is accepted only when tests prove:
+
+1. At least two controlled source promises overlap before either resolves, and
+ active source count never exceeds `maxConcurrentSourceNodes`.
+2. A finding event is observable before all sources finish.
+3. One rejected source still yields successful sibling findings and a partial
+ outcome.
+4. Abort reaches active source/provider operations.
+5. `run()` and `stream()` produce equivalent final state.
+6. OpenAI structured output parses through the supplied Zod schema and records
+ budget usage.
+7. Langfuse root/source/Profile/Analyst observations, masking, scores, and flush
+ still work without a LangChain callback.
+8. No `@langchain/*` or `@langfuse/langchain` import remains.
+
+## Non-goals
+
+- Fix unrelated current UI, crawl-policy, or provenance worktree changes.
+- Add AI tools or an agent loop.
+- Change SSE payloads, database schema, cache behavior, UI, or evidence ranking.
+- Replace Langfuse/OTel.
+
+## Rollback
+
+The migration is one focused dependency/orchestration change. Rollback restores
+the previous workflow, state annotation, LangChain OpenAI adapter, callback
+creation, and removed dependencies together; do not run mixed native and graph
+orchestration paths behind a feature flag.
diff --git a/docs/ticket/TASK-3.md b/docs/ticket/TASK-3.md
new file mode 100644
index 0000000..6791d00
--- /dev/null
+++ b/docs/ticket/TASK-3.md
@@ -0,0 +1,375 @@
+# TASK-3 — PartnerIQ LangGraph Orchestration & Langfuse Cloud
+
+> **Execution:** Implement ticket-by-ticket. Tickets in the same wave may run in parallel only when their file scopes do not overlap. Every ticket follows RED → GREEN → review → commit.
+
+**Status:** Completed ✅
+
+**Branch:** `codex/partneriq-langgraph-langfuse`
+
+**Goal:** Chuyển workflow research doanh nghiệp sang LangGraph song song có giới hạn, dùng LangChain tại LLM boundary và quan sát toàn bộ run bằng Langfuse Cloud mà không đổi UI/SSE contract.
+
+**Design:** [`docs/superpowers/specs/2026-08-25-partneriq-langgraph-langfuse-design.md`](../superpowers/specs/2026-08-25-partneriq-langgraph-langfuse-design.md)
+
+**Implementation plan:** [`docs/superpowers/plans/2026-08-25-partneriq-langgraph-langfuse.md`](../superpowers/plans/2026-08-25-partneriq-langgraph-langfuse.md)
+
+## Global constraints
+
+- Vercel hosts PartnerIQ; Langfuse Cloud only receives telemetry.
+- Client disconnect cancels the run; no queue, durable resume, Agent Server, or checkpointer.
+- Preserve existing `StreamEvent` names and payloads.
+- Reuse current search, scraper, registry, profile, analyst, and storage modules.
+- No `Send`, LLM query planner, `createAgent`, ReAct loop, vector store, or new provider in this epic.
+- One retry owner per operation; retry only timeout, 429, 5xx, and network-reset failures.
+- Enforce call, token, and concurrency limits before spending.
+- Never export raw scraped pages, secrets, authorization/cookie headers, email, or phone to Langfuse.
+- Each ticket stages and commits only its declared files.
+
+## Dependency map
+
+```text
+T3.1
+├── T3.2 ─┐
+└── T3.3 ─┴── T3.4
+ ├── T3.5
+ └── T3.6 ─── T3.7 ─── T3.8
+```
+
+Recommended execution waves:
+
+| Wave | Tickets | Parallel rule |
+|---|---|---|
+| 1 | T3.1 | Sequential compatibility gate |
+| 2 | T3.2, T3.3 | Parallel after T3.1; coordinate the small `llm/types.ts` seam before merge |
+| 3 | T3.4 | Integrates outputs of Wave 2 |
+| 4 | T3.5, T3.6 | Parallel; profile files and route/stream files do not overlap |
+| 5 | T3.7 | Starts after route integration is stable |
+| 6 | T3.8 | Final integrated verification only |
+
+---
+
+## T3.1 — Runtime and dependency compatibility gate
+
+**Depends on:** none
+
+**Goal:** Pin the exact framework/telemetry versions and prove they compile with the repository's Next.js, Zod, and TypeScript setup before production code depends on them.
+
+**Files:**
+
+- `package.json`
+- `package-lock.json`
+- `tests/unit/langgraph-runtime.test.ts`
+
+**Deliverables:**
+
+- Exact dependencies:
+ - `@langchain/langgraph@1.4.12`
+ - `@langchain/core@1.2.9`
+ - `@langchain/openai@1.5.10`
+ - `@langfuse/tracing@5.10.1`
+ - `@langfuse/otel@5.10.1`
+ - `@langfuse/langchain@5.10.1`
+ - `@opentelemetry/sdk-node@0.221.0`
+- A minimal Zod-backed `StateGraph` compile/invoke regression test.
+- Lockfile committed with no peer-dependency override.
+
+**Acceptance:**
+
+- `npm test -- tests/unit/langgraph-runtime.test.ts` passes.
+- `npm run typecheck` passes.
+- No dependency is installed with a floating range.
+
+**Commit:** `chore(ai): pin graph and tracing packages`
+
+---
+
+## T3.2 — Evidence, coverage queries, and pre-spend budgets
+
+**Depends on:** T3.1
+
+**Goal:** Produce deterministic evidence regardless of parallel completion order, expand bounded coverage without an LLM planner, and enforce resource limits before provider/model calls.
+
+**Files:**
+
+- `src/lib/types.ts`
+- `src/adapters/llm/types.ts`
+- `src/config/index.ts`
+- `src/modules/research/evidence.ts`
+- `src/modules/research/queries.ts`
+- `src/modules/research/budget.ts`
+- `src/modules/research/sources/web-search.ts`
+- `src/modules/research/sources/news.ts`
+- `.env.example`
+- `tests/unit/research-evidence.test.ts`
+- `tests/unit/research-queries.test.ts`
+- `tests/unit/research-budget.test.ts`
+- `tests/unit/sources.test.ts`
+
+**Deliverables:**
+
+- `SourceExecutionResult` with `succeeded | failed | skipped`.
+- URL validation, canonical deduplication, source-priority ordering, and `complete | partial | failed` outcome.
+- Deterministic query categories capped at six: identity, products/services, leadership, recent activity, risk, tax/legal.
+- `additionalKeywords` replaces a remaining slot; it never bypasses the cap.
+- Per-run LLM call/token budget and FIFO provider-slot limiter.
+- Config defaults:
+ - `MAX_QUERIES_PER_RESEARCH=6`
+ - `MAX_CONCURRENT_SOURCE_NODES=3`
+ - `MAX_CONCURRENT_PROVIDER_CALLS=2`
+
+**Acceptance:**
+
+- Reordered source results produce identical prepared evidence order.
+- Invalid/non-HTTP(S) URLs are removed.
+- Duplicate canonical URLs keep the higher-confidence finding.
+- A third provider call waits while two slots are occupied.
+- A model call is rejected before exceeding call/token limits.
+- Targeted evidence/query/budget/source tests pass.
+
+**Commit:** `feat(research): enforce deterministic evidence budgets`
+
+---
+
+## T3.3 — LangChain-backed LLM adapter
+
+**Depends on:** T3.1
+
+**Goal:** Use LangChain for model invocation and structured output without leaking LangChain types into profile or analyst modules.
+
+**Files:**
+
+- `src/adapters/llm/types.ts`
+- `src/adapters/llm/openai.ts`
+- `tests/unit/langchain-llm.test.ts`
+- `tests/integration/profile-module.test.ts`
+- `tests/unit/analyst.test.ts`
+
+**Deliverables:**
+
+- Existing `LLMAdapter.complete`, `completeStructured`, and `stream` signatures remain the application port.
+- `ChatOpenAI` is the default model implementation.
+- `withStructuredOutput` consumes caller-owned Zod schemas.
+- Abort signal, callbacks, normalized usage metadata, and `LLMBudget` are forwarded.
+- Exactly one model retry layer.
+- Injectable fake model factory for offline contract tests.
+
+**Acceptance:**
+
+- Plain, structured, streaming, cancellation, callbacks, and usage mapping tests pass.
+- Profile and analyst tests pass without importing LangChain.
+- No second output schema is introduced.
+
+**Commit:** `refactor(llm): use langchain model contracts`
+
+---
+
+## T3.4 — Parallel LangGraph workflow
+
+**Depends on:** T3.2, T3.3
+
+**Goal:** Replace sequential research and route-owned business orchestration with a deterministic StateGraph that preserves partial success.
+
+**Files:**
+
+- `src/modules/research/index.ts`
+- `src/modules/workflow/state.ts`
+- `src/modules/workflow/index.ts`
+- `tests/integration/research-module.test.ts`
+- `tests/integration/research-workflow.test.ts`
+
+**Deliverables:**
+
+- Existing source functions exposed as source runners; provider logic is not rewritten.
+- Static nodes: `web_search`, `website`, `news`, `registry`, `linkedin`.
+- LinkedIn returns `skipped` when no URL exists.
+- `sourceResults` append reducer; `prepare_evidence` alone writes final `findings`.
+- Downstream order:
+
+```text
+prepare_evidence
+→ load_existing_profile
+→ build_profile
+→ persist_profile
+→ build_and_persist_diff
+→ analyze
+→ END
+```
+
+- Source errors become typed results after bounded retries; they do not escape the parallel superstep.
+- Analyst failure is partial/non-fatal; profile or persistence failure is fatal.
+
+**Acceptance:**
+
+- More than one source runs concurrently.
+- `dispatched = succeeded + failed + skipped` for every fixture.
+- One source timeout preserves sibling findings and reaches a partial result.
+- Zero findings produce no profile write.
+- Source completion order does not change prepared evidence order.
+
+**Commit:** `feat(research): orchestrate sources with langgraph`
+
+---
+
+## T3.5 — Untrusted-evidence and conflict policy
+
+**Depends on:** T3.4
+
+**Goal:** Make scraped content an explicit untrusted-data boundary and encode source precedence before profile synthesis.
+
+**Files:**
+
+- `src/modules/profile/index.ts`
+- `tests/integration/profile-module.test.ts`
+
+**Deliverables:**
+
+- Every finding is wrapped in an `UNTRUSTED_SOURCE_DATA` delimiter.
+- System prompt explicitly forbids following instructions found inside source data.
+- Field-sensitive precedence:
+ - legal identity: registry → official website → other evidence;
+ - products/markets: official website → registry → other evidence;
+ - recent activity/risk: news and official announcements remain cited evidence and never override legal identity.
+- Existing per-finding content cap and Zod structured output remain.
+
+**Acceptance:**
+
+- Prompt-injection fixture remains visible as evidence but is inside the untrusted boundary.
+- Conflict fixture places policy before evidence blocks.
+- Profile and diff tests pass.
+
+**Commit:** `fix(profile): isolate untrusted source evidence`
+
+---
+
+## T3.6 — Vercel SSE route and cancellation
+
+**Depends on:** T3.4
+
+**Goal:** Make the API route a thin graph-stream adapter and stop all work when the client aborts.
+
+**Files:**
+
+- `src/app/api/research/route.ts`
+- `src/lib/stream.ts`
+- Source adapters requiring abort propagation
+- `tests/e2e/workflow-e2e.test.ts`
+
+**Deliverables:**
+
+- Route exports `runtime = "nodejs"` and `maxDuration = 300`.
+- Workflow deadline is 285 seconds, leaving 15 seconds for terminal SSE and telemetry flush.
+- One `researchRunId` per request.
+- Request signal reaches graph, fetch-based adapters, and the Node direct scraper socket.
+- One guarded writer close in `finally`; no intermediate branch closes the stream.
+- Existing SSE events remain compatible.
+
+**Acceptance:**
+
+- A normal run emits `research:start`, profile, diff, analysis, and exactly one `done`.
+- Invalid input remains HTTP 400.
+- Provider errors retain their useful message.
+- Aborted request saves no profile/diff and closes the stream once.
+- E2E and typecheck pass.
+
+**Commit:** `refactor(api): stream the research graph`
+
+---
+
+## T3.7 — Langfuse Cloud observability
+
+**Depends on:** T3.6
+
+**Goal:** Produce one privacy-minimized trace per research run with workflow/source/model hierarchy and deterministic quality scores.
+
+**Files:**
+
+- `src/instrumentation.ts`
+- `src/observability/langfuse.ts`
+- `src/modules/workflow/index.ts`
+- `src/app/api/research/route.ts`
+- `.env.example`
+- `tests/unit/langfuse-observability.test.ts`
+
+**Deliverables:**
+
+- Next.js Node-only instrumentation startup.
+- One `partneriq.research` trace with sibling `source.*` observations.
+- LangChain/LangGraph callback captures model generations once; no duplicate manual generation span.
+- Metadata: `researchRunId`, internal `companyId`, requested sources, app version.
+- Client-side masking removes secrets, headers, contact data, and raw page content while preserving valid JSON.
+- Root level: default for complete, warning for partial, error for failed/cancelled.
+- Deterministic scores:
+ - `source_coverage`
+ - `profile_schema_valid`
+ - `profile_confidence`
+ - `analysis_schema_valid`
+ - `research_success`
+- One flush after root completion; no per-request SDK shutdown.
+- Required `LANGFUSE_BASE_URL` comes from the selected Cloud project region; application code does not hard-code a region.
+
+**Acceptance:**
+
+- Unit tests make no network call.
+- Trace-shape mock sees one root, source siblings, and nested model generations.
+- Masking output contains no configured secret/contact/raw-content fixtures and remains JSON parseable.
+- Partial run produces `source_coverage=0.75` for three successes, one failure, and one skipped source.
+
+**Commit:** `feat(observability): trace research in langfuse`
+
+---
+
+## T3.8 — Release and preview verification
+
+**Depends on:** T3.5, T3.7
+
+**Goal:** Prove the integrated workflow is correct, faster than the sequential baseline, privacy-safe in Langfuse, and deployable on Vercel.
+
+**Files:**
+
+- `README.md`
+- `docs/plan/ARCHITECTURE.md`
+- Tests or production files required only to correct failures introduced by T3.1-T3.7
+
+**Deliverables:**
+
+- Updated architecture and operational setup.
+- Vercel env/deadline/cancellation documentation.
+- Langfuse Cloud endpoint, masking, trace lookup, and rollback documentation.
+- Mock latency benchmark with source delays 100/200/300/400 ms:
+ - parallel run under 650 ms;
+ - sequential baseline approximately 1,000 ms.
+- Fixture coverage checks for FPT, Vingroup, and MISA.
+- One preview Langfuse trace reviewed manually.
+
+**Acceptance:**
+
+```bash
+npm run lint
+npm run typecheck
+npm test
+npm run build
+```
+
+All commands exit 0. The handoff records current test counts rather than copying the previous `102/102` result.
+
+Preview verification confirms:
+
+- one trace per research run;
+- source observations are siblings;
+- model usage/cost appears once;
+- no raw scraped page, API key, email, or phone is exported;
+- client abort creates no persisted profile version;
+- `dispatched = succeeded + failed + skipped`.
+
+**Commit:** `docs(research): document graph operations`
+
+---
+
+## Rollback
+
+- Set `LANGFUSE_ENABLED=false` to disable export without changing workflow behavior.
+- Revert T3.4 and T3.6 together to restore sequential orchestration; do not maintain two long-lived production orchestrators.
+- Remove framework dependencies only after the old route is restored and the full suite passes.
+
+## Deferred follow-up epic
+
+An LLM query planner and `Send` map-reduce remain deferred. Open a separate epic only when a 20-50-company offline benchmark demonstrates that the deterministic six-query matrix misses the agreed field/citation threshold.
diff --git a/docs/ticket/TASK-4.md b/docs/ticket/TASK-4.md
new file mode 100644
index 0000000..7bccf8e
--- /dev/null
+++ b/docs/ticket/TASK-4.md
@@ -0,0 +1,1227 @@
+# TASK-4 — Evidence Provenance & In-App Source Preview
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan sprint-by-sprint. Every production change follows RED → GREEN → review → commit.
+
+**Status:** ✅ COMPLETE (All 8 Sprints Implemented, Tested, and Verified)
+
+**Goal:** Thay các link Google suy đoán bằng bằng chứng thật hiển thị ngay trong app, giữ provenance xuyên suốt pipeline và mô tả độ tin cậy bằng tín hiệu kiểm chứng được thay vì nhãn “thật/giả” hoặc phần trăm thiếu căn cứ.
+
+**Architecture:** Giữ nguyên workflow LangGraph, adapter ports, SSE route và JSONB storage. Làm sâu seam `prepareEvidence`: news discovery dùng Serper News, bài báo được trích xuất qua scraper hiện có, metadata/paywall/robots được chuẩn hóa, ProfileModule và AnalystModule chỉ trả citation URL thuộc evidence đầu vào. `CompanyProfile.sources` là nguồn dữ liệu duy nhất cho source preview nên cache hiện tại không cần bảng mới.
+
+**Tech Stack:** Next.js 16.3.2 App Router, React 19.2.8, TypeScript 6.0.2, Zod 4.4.3, LangGraph 1.4.12, Vitest 4.1.11, Serper News, existing `SafeDirect → Jina → TinyFish` scraper, `cheerio@1.2.0`, `robots-parser@3.0.1`, Supabase JSONB.
+
+**Spec:** `docs/research/2026-08-28-news-source-trust.md`
+
+## Global Constraints
+
+- Không hiển thị `True/False`, “báo thật/báo rác”, “đã xác minh” hoặc phần trăm độ tin cậy cho từng bài.
+- Tách source signals khỏi claim verification: metadata tốt không chứng minh nội dung đúng.
+- Broad search là mặc định; request-level domain policy chỉ có ba mode `broad | prefer | only`. Registry và official website không bị loại bởi policy dành cho search results.
+- Không dùng iframe trong P0. Preview là server-extracted plain text; luôn có CTA mở bài gốc.
+- Không render raw HTML hoặc dùng `dangerouslySetInnerHTML`.
+- Paywall rõ ràng hoặc `isAccessibleForFree=false`: không giữ body; chỉ metadata + search snippet + CTA.
+- Tôn trọng `nosnippet`, `max-snippet`, `data-nosnippet` và robots decision như policy input; chúng không được mô tả là giấy phép bản quyền.
+- Mặc định chỉ persist metadata, excerpt tối đa 800 ký tự và SHA-256 fingerprint; không persist full HTML/article body.
+- Copyright theo khu vực và publisher terms là release requirement; ticket không tự đưa ra kết luận pháp lý.
+- Crawl chỉ dùng URL `http/https` đã qua SSRF guard của Task 2; không bypass paywall, CAPTCHA, Cloudflare hoặc anti-bot.
+- Rate limit theo exact hostname ở mức process-local; không thêm public-suffix dependency, Redis hoặc queue trong Task 4.
+- Không thêm fake-news classifier, domain reputation API trả phí, vector store, C2PA verification hoặc browser automation.
+- Giữ `overallConfidence` trong schema để đọc cache cũ, nhưng UI không dùng nó như truth score.
+- Mọi URL do LLM trả về phải nằm trong evidence allowlist; URL lạ bị loại trước khi persist.
+- Cached snapshot cũ không đạt schema mới phải đi qua cơ chế `cache_invalid → live research` hiện có.
+- UI dùng native `