← Requirements
← Pitch Deck →
Technical Architecture
Founder Ghosting Detector — Platform Infrastructure
1. Technology Stack
| Layer | Technology | Purpose | Why |
| Frontend Framework |
SvelteKit |
Full-stack web app, SSR, routing |
Fast HMR dev, small bundle, great DX, Supabase integration |
| Backend Runtime |
Deno |
API routes, server logic |
Native TypeScript, secure by default, deno run simplicity for edge deploys; avoids Node.js overhead |
| Database |
Supabase PostgreSQL |
Relational data, auth, storage |
Built-in auth, row-level security, realtime subscriptions, pgvector for AI embeddings |
| AI / ML |
Ollama Llama 3.1 8B |
Sentiment analysis, content moderation, scoring engine |
Self-hosted on Mac Mini M2 (below AWS cost for our scale), full control, no data leaves infra |
| Edge CDN |
Cloudflare Pages |
Frontend deploy, global static edge |
Free tier, global CDN, native SvelteKit adapter, zero-config preview deploys |
| Serverless Compute |
Cloudflare Workers |
Webhook handlers, rate limiting, proxy |
Runs close to Supabase, sub-ms cold starts, generous free tier |
| Email |
Resend |
Transactional email (alerts, digests) |
Deno-native SDK, React Email templates, $20/month for 50K emails |
| Payments |
Stripe |
Subscription billing |
Industry standard, supports B2B invoicing, usage-based billing for API |
| Monitoring |
Betterstack Sentry |
Uptime monitoring, error tracking |
Betterstack: $7/month for 10 monitors. Sentry: free tier up to 5K events/month |
| Search |
Supabase Full-Text |
Founder/company name search |
pg_trgm trigram matching covers 95% of search needs without external search service |
| Vector Search |
pgvector |
Similar report clustering, duplicate detection |
Stored in Supabase, used for AI narrative similarity matching |
2. Infrastructure Overview
┌─────────────────────────────────────────────────────────────┐
│ CANDIDATE / USER (Browser) │
└────────────────────────────┬──────────────────────────────────────┘
│ HTTPS
▼
┌─────────────────────────────────────────────────────────────────┐
│ Cloudflare Edge (Pages + Workers) │
│ • Static assets (CDN cached) │
│ • Auth middleware (via CF Access — optional enterprise) │
│ • Webhook handler (/api/webhooks/stripe, /api/webhooks/resend) │
│ • Rate limiter (CF Workers KV) │
└────┬────────────────────────────┬────────────────────────────────┘
│ │
│ SvelteKit SSR/CSR │ Direct API calls (Pro API key)
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ SvelteKit App (Deno runtime) │
│ /routes │
│ ├─ (app)/ ← authenticated layout │
│ ├─ (marketing)/ ← public landing, pricing │
│ └─ api/ ← internal API routes │
│ ├─ /api/reports POST (create report) │
│ ├─ /api/founders GET (search, profile) │
│ ├─ /api/alerts GET/POST/DELETE │
│ ├─ /api/scores GET (computed reputation scores) │
│ └─ /api/moderation POST (flag/report review) │
└────┬─────────────────────────────────────────────────────────────┘
│
│ PostgreSQL queries + Auth
▼
┌─────────────────────────────────────────────────────────────────┐
│ Supabase (Managed Postgres) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ users │ │ founders │ │ reports │ │ watchlists │ │
│ │ + │ │ + │ │ + │ │ + │ │
│ │ accounts │ │ companies│ │ narratives│ │ alerts │ │
│ │ subscriptions│ │ scores │ │ embeddings│ │ subscriptions│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
│ │
│ Row-Level Security (RLS): │
│ • Free users: read scores only, write reports │
│ • Pro users: read all + access narratives │
│ • B2B users: read own company reports only │
└────┬─────────────────────────────────────────────────────────────┘
│
│ Report submitted event (Supabase Realtime / pg_notify)
▼
┌─────────────────────────────────────────────────────────────────┐
│ Ollama Server (Mac Mini M2) │
│ Running: Llama 3.1 8B Instruct + Nomic Embed Text │
│ Host: 192.168.1.100:11434 (internal LAN) │
│ │
│ Pipeline: │
│ 1. Report submitted → embed narrative → store pgvector │
│ 2. Duplicate detection → similarity search against prior reports│
│ 3. Sentiment analysis → classify severity (1-5) │
│ 4. Moderation check → flag if content violates ToS │
│ 5. Score recompute → trigger Supabase function │
└─────────────────────────────────────────────────────────────────┘
│
│ Score update + alert dispatch
▼
┌─────────────────────────────────────────────────────────────────┐
│ External Services │
│ • Stripe → subscription webhooks (create/cancel/update) │
│ • Resend → transactional email + digest delivery │
│ • Sentry ← error reports from SvelteKit │
│ • Betterstack ← uptime checks on Ollama + Supabase │
└─────────────────────────────────────────────────────────────────┘
3. Data Model
All tables live in Supabase PostgreSQL. RLS policies enforce access control at the row level.
3.1 founders
CREATE TABLE founders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
full_name TEXT NOT NULL,
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
title TEXT,
linkedin_url TEXT,
avatar_url TEXT,
-- Scores (computed, updated via Supabase cron or trigger)
ghost_score NUMERIC(3,1) DEFAULT NULL, -- 0.0–100.0, NULL = insufficient data
ghost_count INTEGER DEFAULT 0,
total_reports INTEGER DEFAULT 0,
score_confidence TEXT DEFAULT 'low', -- low / medium / high
-- Stage breakdown scores
score_prescreen NUMERIC(3,1) DEFAULT NULL,
score_technical NUMERIC(3,1) DEFAULT NULL,
score_onsite NUMERIC(3,1) DEFAULT NULL,
score_offer NUMERIC(3,1) DEFAULT NULL,
score_post_rejection NUMERIC(3,1) DEFAULT NULL,
-- Reputation tier (computed from ghost_score)
reputation_tier TEXT DEFAULT 'unknown' CHECK (
reputation_tier IN ('reliable', 'proceed_with_care', 'high_risk', 'unknown')
),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_founders_name ON founders USING gin(to_tsvector('english', full_name));
CREATE INDEX idx_founders_company ON founders(company_id);
3.2 companies
CREATE TABLE companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
website TEXT,
stage TEXT CHECK (stage IN ('pre_seed','seed','series_a','series_b_plus')),
industry TEXT,
linkedin_url TEXT,
crunchbase_url TEXT,
-- Aggregate company-level score
company_ghost_score NUMERIC(3,1) DEFAULT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_companies_name ON companies USING gin(to_tsvector('english', name));
3.3 reports
CREATE TABLE reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Core relations
founder_id UUID NOT NULL REFERENCES founders(id) ON DELETE CASCADE,
reporter_id UUID REFERENCES auth.uid() ON DELETE SET NULL,
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
-- Job context
role_title TEXT NOT NULL,
role_level TEXT CHECK (role_level IN ('ic','lead','manager','director','vp')),
role_department TEXT CHECK (role_department IN (
'engineering','design','product','ops','sales','marketing','hr','finance','other'
)),
-- Ghosting details
ghosting_stage TEXT NOT NULL CHECK (ghosting_stage IN (
'prescreen','technical','onsite','offer','post_rejection'
)),
last_contact_at DATE NOT NULL,
expected_reply_at DATE,
days_ghosted INTEGER GENERATED ALWAYS AS (
expected_reply_at - last_contact_at
) STORED,
-- Narrative
narrative TEXT NOT NULL CHECK (char_length(narrative) BETWEEN 50 AND 2000),
narrative_embedding vector(768), -- pgvector for Ollama embeddings
-- Moderation & quality
is_verified BOOLEAN DEFAULT FALSE, -- reporter is a verified account holder
is_anonymous BOOLEAN DEFAULT FALSE,
sentiment_score NUMERIC(2,1), -- 1.0–5.0 from Ollama
moderation_status TEXT DEFAULT 'pending' CHECK (
moderation_status IN ('pending','approved','flagged','removed')
),
ai_flags TEXT[], -- ['potential_defamation', 'suspicious_timing']
upvote_count INTEGER DEFAULT 0,
downvote_count INTEGER DEFAULT 0,
-- Dispute
is_disputed BOOLEAN DEFAULT FALSE,
dispute_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_reports_founder ON reports(founder_id);
CREATE INDEX idx_reports_company ON reports(company_id);
CREATE INDEX idx_reports_stage ON reports(ghosting_stage);
CREATE INDEX idx_reports_created ON reports(created_at DESC);
-- Vector similarity search
CREATE INDEX idx_reports_embedding ON reports USING ivfflat (narrative_embedding vector_cosine_ops);
3.4 users
CREATE TABLE users (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
-- Profile
display_name TEXT,
email TEXT NOT NULL,
avatar_url TEXT,
bio TEXT,
-- Subscription
plan TEXT DEFAULT 'free' CHECK (
plan IN ('free','pro','power','b2b_starter','b2b_enterprise')
),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
subscription_status TEXT DEFAULT 'trialing',
current_period_end TIMESTAMPTZ,
-- Usage limits
searches_this_month INTEGER DEFAULT 0,
searches_limit INTEGER DEFAULT 0, -- 0 = unlimited (pro/unlimited)
-- B2B fields
company_name TEXT,
company_role TEXT,
is_b2b BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_stripe ON users(stripe_customer_id);
3.5 watchlists & alerts
CREATE TABLE watchlists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
founder_id UUID REFERENCES founders(id) ON DELETE CASCADE,
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, founder_id),
UNIQUE(user_id, company_id)
);
CREATE TABLE alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
watchlist_id UUID REFERENCES watchlists(id) ON DELETE CASCADE,
alert_type TEXT NOT NULL CHECK (
alert_type IN ('new_report','score_drop','weekly_digest','reputation_recovery')
),
delivery_method TEXT DEFAULT 'email' CHECK (delivery_method IN ('email','push','none')),
is_enabled BOOLEAN DEFAULT TRUE,
last_sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
3.6 B2B company accounts
CREATE TABLE b2b_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
owner_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
stripe_invoice_id TEXT,
plan TEXT DEFAULT 'starter',
team_seats INTEGER DEFAULT 1,
is_active BOOLEAN DEFAULT TRUE,
billing_email TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
renewed_at TIMESTAMPTZ
);
4. AI Pipeline (Ollama on Mac Mini M2)
Ollama runs on a dedicated Mac Mini M2 (24GB RAM) in our office, connected via internal LAN to the Supabase instance. This keeps all AI inference local — no candidate data, narratives, or scores ever leave our infra.
Models deployed:
llama3.1:8b-instruct-fp16 — primary inference (sentiment, moderation, scoring)
nomic-embed-text:latest — narrative embeddings for pgvector similarity search
4.1 Pipeline: Report Submission
1
HTTP POST /api/reports — Candidate submits ghosting report via SvelteKit form action. Validated, RLS check (user must be authenticated or anonymous).
2
DB Insert — Report row created with moderation_status = 'pending'. Supabase realtime fires a database webhook via pg_trigger.
3
Webhook → Cloudflare Worker — pg_notify → Cloudflare Worker queue. Worker calls Ollama async:
// Worker: /api/internal/process-report
// Called by Supabase pg_notify webhook
const report = await supabase
.from('reports')
.select('*, founders(full_name), companies(name)')
.eq('id', reportId)
.single();
// Step A: Embed narrative
const embedRes = await fetch('http://192.168.1.100:11434/api/embeddings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'nomic-embed-text',
prompt: report.narrative
})
});
const { embedding } = await embedRes.json();
// Step B: Sentiment analysis
const sentimentRes = await fetch('http://192.168.1.100:11434/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'llama3.1:8b-instruct-fp16',
prompt: `Analyze this ghosting report and respond with ONLY a JSON object:
{
"sentiment_score": 1.0-5.0,
"severity": "low|medium|high",
"flags": ["flag1", "flag2"],
"summary": "one sentence"
}
Report: "${report.narrative.substring(0, 500)}"
Response:`
})
});
const sentimentText = await sentimentRes.text();
// Parse JSON from response...
const sentiment = JSON.parse(extractJSON(sentimentText));
// Step C: Duplicate detection via pgvector
const dupRes = await supabase.rpc('find_similar_reports', {
search_embedding: embedding,
match_threshold: 0.85,
match_count: 3
});
const duplicates = dupRes.data;
// Step D: Moderation check (auto-reject obviously abusive content)
const is_flagged = sentiment.flags.some(f =>
['threat', 'doxxing', 'personal_attack_non_ghosting'].includes(f)
);
await supabase.from('reports').update({
narrative_embedding: embedding,
sentiment_score: sentiment.sentiment_score,
ai_flags: sentiment.flags,
moderation_status: is_flagged ? 'flagged' : 'approved',
updated_at: new Date().toISOString()
}).eq('id', reportId);
if (duplicates.length > 0) {
await supabase.from('reports').update({ duplicate_of: duplicates[0].id }).eq('id', reportId);
}
// Step E: Trigger score recompute
await supabase.functions().invoke('recompute-founder-score', {
body: { founderId: report.founder_id }
});
4.2 Pipeline: Reputation Score Computation
-- Supabase SQL function: recompute_founder_score(founder_uuid)
CREATE OR REPLACE FUNCTION recompute_founder_score(f_id UUID)
RETURNS VOID AS $$
DECLARE
all_reports INTEGER;
ghost_reports INTEGER;
base_score NUMERIC(3,1);
vol_boost NUMERIC(3,1);
conf TEXT;
stage_rec RECORD;
BEGIN
-- Count total and ghosted reports for this founder
SELECT COUNT(*), COUNT(*) FILTER (WHERE ghosting_stage NOTNULL)
INTO all_reports, ghost_reports
FROM reports
WHERE founder_id = f_id
AND moderation_status = 'approved'
AND created_at > NOW() - INTERVAL '24 months';
IF all_reports < 3 THEN
-- Insufficient data
UPDATE founders SET
ghost_score = NULL,
total_reports = all_reports,
ghost_count = ghost_reports,
score_confidence = 'low',
reputation_tier = 'unknown',
updated_at = NOW()
WHERE id = f_id;
RETURN;
END IF;
-- Base score: 100 * (1 - ghost_rate)
base_score := ROUND((1.0 - (ghost_reports::NUMERIC / all_reports)) * 100, 1);
-- Volume boost: more reports = higher confidence = slight score tightening
-- e.g. 3-5 reports: slight buffer. 20+ reports: full confidence.
vol_boost := LEAST(all_reports / 20.0, 1.0);
-- Final score
base_score := ROUND(base_score * (0.9 + 0.1 * vol_boost), 1);
base_score := GREATEST(0, LEAST(100, base_score));
-- Confidence
conf := CASE
WHEN all_reports >= 20 THEN 'high'
WHEN all_reports >= 8 THEN 'medium'
ELSE 'low'
END;
-- Per-stage scores
FOR stage_rec IN
SELECT ghosting_stage,
COUNT(*) as total,
COUNT(*) FILTER (WHERE ghosting_stage NOTNULL) as ghosted
FROM reports
WHERE founder_id = f_id
AND moderation_status = 'approved'
AND created_at > NOW() - INTERVAL '24 months'
GROUP BY ghosting_stage
LOOP
EXECUTE format('UPDATE founders SET score_%I = %s WHERE id = %s',
stage_rec.ghosting_stage,
ROUND((1.0 - (stage_rec.ghosted::NUMERIC / NULLIF(stage_rec.total,0))) * 100, 1),
f_id
);
END LOOP;
-- Reputation tier
UPDATE founders SET
ghost_score = base_score,
total_reports = all_reports,
ghost_count = ghost_reports,
score_confidence = conf,
reputation_tier = CASE
WHEN base_score >= 80 THEN 'reliable'
WHEN base_score >= 50 THEN 'proceed_with_care'
ELSE 'high_risk'
END,
updated_at = NOW()
WHERE id = f_id;
-- Trigger alerts for watchers
PERFORM pg_notify('score_updated', json_build_object(
'founder_id', f_id,
'new_score', base_score,
'report_count', all_reports
)::text);
END;
$$ LANGUAGE plpgsql;
5. Deployment Strategy
Frontend: Cloudflare Pages + SvelteKit
# Build: svelte.config.js with @sveltejs/adapter-cloudflare
npm run build
# Output: /build directory → Cloudflare Pages
# Environment variables set in Cloudflare dashboard:
PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
PUBLIC_SUPABASE_ANON_KEY=eyJ...
PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
PUBLIC_APP_URL=https://founderghostingdetector.com
# Branches:
# main → staging.founderghostingdetector.com (auto-deploy)
# production → founderghostingdetector.com (manual gate)
API / Server Logic: Cloudflare Workers (Deno)
# Webhook handler and internal API routes run as Cloudflare Workers
# SvelteKit server routes deployed as Cloudflare Workers via adapter
# Rate limiting: Workers KV
const KV = new KVNamespace('ratelimits');
async function checkRateLimit(userId, plan) {
const limit = plan === 'pro' ? 100 : 10; // requests per minute
const current = parseInt(await KV.get(`rl:${userId}`) || '0');
if (current >= limit) throw new Error('Rate limited');
await KV.put(`rl:${userId}`, (current + 1).toString(), { expirationTtl: 60 });
}
AI Server: Mac Mini M2 + Ollama
# On the Mac Mini M2 (office, 192.168.1.100)
# Boot Ollama on startup (launchd or systemd)
# systemd unit: /etc/systemd/system/ollama.service
[Unit]
Description=Ollama Local AI Server
After=network.target
[Service]
Type=simple
User=ollama
ExecStart=/usr/local/bin/ollama serve
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
# Pull models
ollama pull llama3.1:8b-instruct-fp16
ollama pull nomic-embed-text
# Expose only to internal LAN (firewall blocks external access)
# Cloudflare Tunnel (cloudflared) exposes :11434 to Workers securely
cloudflared tunnel --quick --token $TUNNEL_TOKEN
# Tunnel endpoint: http://192.168.1.100:11434 → cf-tunnel.ollama.internal
Supabase
# Supabase managed Postgres on AWS
# Self-hosted projects also supported via docker/supabase/docker-compose.yml
# Key configurations:
# 1. Enable pgvector extension
CREATE EXTENSION IF NOT EXISTS vector;
# 2. Enable realtime
ALTER PUBLICATION supabase_realtime ADD TABLE reports;
# 3. Row-Level Security policies (examples)
ALTER TABLE reports ENABLE ROW LEVEL SECURITY;
-- Public read: scores only (no narratives)
CREATE POLICY "Anyone can read scores"
ON reports FOR SELECT
USING (true); -- narratives hidden via SELECT column permissions in app
-- Pro users can read narratives
CREATE POLICY "Pro users read narratives"
ON reports FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.uid()
AND users.plan IN ('pro', 'power', 'b2b_starter', 'b2b_enterprise')
)
);
-- Anyone can insert reports
CREATE POLICY "Anyone can submit reports"
ON reports FOR INSERT WITH CHECK (true);
-- Moderation: service role only
CREATE POLICY "Service role can update moderation"
ON reports FOR UPDATE
TO service_role
USING (true);
CI/CD
# GitHub Actions: .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main, production]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v1
- run: deno test --allow-all
- run: npm run check # SvelteKit type check
deploy-staging:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: founder-ghosting-detector-staging
directory: build
deploy-production:
needs: test
if: github.ref == 'refs/heads/production'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy to Cloudflare Pages (production)
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: founder-ghosting-detector
directory: build
6. Security & Privacy
| Concern | Implementation |
| Data at rest | Supabase encryption at rest (AES-256). Ollama embeddings stored locally on Mac Mini encrypted volume (FileVault). |
| Data in transit | HTTPS everywhere. CF Tunnel for Ollama access (mTLS). |
| Auth | Supabase Auth (email/password + Google OAuth). B2B accounts get SSO via Google Workspace. |
| API keys | Pro API keys hashed in DB (SHA-256). Revealed once on creation. |
| Anonymous reporters | No IP logged. No device fingerprint. Auth token required but no PII. |
| Founder data rights | Founder profiles are NOT personal data under GDPR (company representative), but we honor deletion requests to be safe. |
| Payments | Stripe handles all card data. We never store raw card numbers. |
| Rate limiting | CF Workers KV per-user rate limits. Burst protection on AI endpoints. |
Architecture v1.0 · April 21, 2026 · SvelteKit · Deno · Supabase · Ollama