← Requirements ← Pitch Deck →

Technical Architecture

Founder Ghosting Detector — Platform Infrastructure
StackSvelteKit · Deno · Supabase · Ollama
EdgeCloudflare Workers + Pages
DBSupabase (PostgreSQL + Auth + Realtime)
AIOllama on Mac Mini M2 (local)
Hostingfly.io for API, CF Pages for frontend
Statusv1.0 — Ready for Dev

1. Technology Stack

LayerTechnologyPurposeWhy
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:

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 Workerpg_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

ConcernImplementation
Data at restSupabase encryption at rest (AES-256). Ollama embeddings stored locally on Mac Mini encrypted volume (FileVault).
Data in transitHTTPS everywhere. CF Tunnel for Ollama access (mTLS).
AuthSupabase Auth (email/password + Google OAuth). B2B accounts get SSO via Google Workspace.
API keysPro API keys hashed in DB (SHA-256). Revealed once on creation.
Anonymous reportersNo IP logged. No device fingerprint. Auth token required but no PII.
Founder data rightsFounder profiles are NOT personal data under GDPR (company representative), but we honor deletion requests to be safe.
PaymentsStripe handles all card data. We never store raw card numbers.
Rate limitingCF Workers KV per-user rate limits. Burst protection on AI endpoints.
Architecture v1.0 · April 21, 2026 · SvelteKit · Deno · Supabase · Ollama