Path selected: B — Multi-Tenant SaaS · Drafted 2026-08-22 · Status: pre-implementation planning
Turn JobRadar — a personally-tuned, single-user AWS pipeline for job discovery, scoring, and
alerting (source repo: jobradar) — into OpenJobRadar: a hosted product where any job seeker
can customize every dimension of discovery, filtering, scoring, values screening, notification,
and cost through friendly surfaces.
Companion audit: every personalization point cited below (
file:linereferences) was verified against the source repo on 2026-08-22. Appendix A is the purge checklist; Appendix C maps which source assets carry forward and how.
Thesis. The hard part of a job search isn't finding postings — it's trusting a filter that knows you. JobRadar's engine (50 ATS adapters + aggregators, hard filters, two-pass Bedrock scoring, explainable verdicts, outcome-driven source control) already works and is measured. Productizing means: (1) zero person-specific facts in code, (2) strict per-user isolation, (3) configuration so approachable that non-engineers succeed, and (4) unit economics where a paid plan comfortably covers a user's Bedrock spend.
Audience breadth (2026-08-22 revision). The prototype served one engineering-leadership search; the product serves knowledge workers across role families: software engineering (IC and leadership), product management, design, research (UX and applied), data/analytics/ML, program management, marketing, operations, and custom families users define. Role family is a first-class configuration dimension — it selects filter vocabularies, rubric archetypes, and onboarding question flows — and every shipped default set (presets, examples, golden sets) must cover at least engineering, product, design, and research before GA.
Non-goals (v1).
- Not a LinkedIn/social network; not an applicant-tracking system for employers.
- Not resume-blasting automation — human-in-the-loop everywhere alerts are acted on.
- No scraping that violates robots.txt / ToS; Gmail ingestion stays consent-first, minimal-scope.
- No enterprise SSO/RBAC/SOC 2 in v1 (tracked post-GA).
| Dimension | Path A (self-host OSS) | Path B (multi-tenant SaaS) — chosen |
|---|---|---|
| User effort | Clone + own AWS account + CDK deploy | Sign up, configure in browser |
| Audience reached | Engineers only | Anyone job-hunting who fits the product |
| Isolation burden | Per-deployment (natural) | Code-level tenancy (the core engineering risk) |
| Cost model | Users pay their own AWS | We pay AWS; billing must cover Bedrock per user |
| Ops burden | Ours per our stack | Ours for everyone, 24/7 |
| Deliverability | Each deployer verifies own domain | Shared sending reputation to engineer carefully |
Path B subsumes most of A's technical work anyway: de-personalized config (M1), parameterized infrastructure, and DRY_RUN-first safety are identical. The CDK app stays parameterized so a future "Enterprise dedicated stack" tier can reuse Path-A packaging without rework.
┌────────────────────── CONTROL PLANE ─────────────────────┐
Browser ── CloudFront ─▶ Astro web app │
│ ├─ Onboarding wizard ├─ Watchlist manager │
│ └─ Settings hub └─ Matches/Pipeline/Trends │
API Gateway ───────────▶ web_api (split) │
│ ├─ authn: Cognito Hosted UI + PKCE (exists) → userId=sub│
│ ├─ settings service (per-user config docs, validated) │
│ ├─ credentials vault (KMS envelope-encrypted BYO keys) │
│ ├─ entitlements+metering (Stripe-backed plan gates) │
│ └─ admin/break-glass (audit-logged, support role) │
└───────────────────────────────────────────────────────────┘
┌────────────────────── DATA PLANE ────────────────────────┐
EventBridge dispatcher │ poll → hard filters → Bedrock score → notify │
(due-work fanout) │ every Lambda receives userId context │
│ fairness lanes (free/plus/pro SQS) + per-user budgets │
SES (product domain → │ digest/alert/brief renderers, per-user quiet hours │
custom-domain wizard)│ │
DynamoDB (all rows │ Seen·Postings·Orgs·Subscriptions·Sends·Applications· │
keyed by userId) │ Settings·Credentials·Entitlements·BudgetLedger·DueWork │
└───────────────────────────────────────────────────────────┘
| # | Decision | Choice | Rationale |
|---|---|---|---|
| AD1 | Tenancy model | Pooled (one stack, row-level ownership), not stack-per-tenant | Seconds-not-minutes provisioning; no idle floor cost per free user; single deploy surface. Escape hatch: keep CDK parameterized for a future dedicated-stack enterprise tier |
| AD2 | Identity | Cognito sub = canonical userId, threaded through every table key, Lambda event, and log line |
Auth already exists in the source repo (web/src/lib/auth.ts, Hosted UI + PKCE); the gap is authorization scoping, not authentication |
| AD3 | Storage | Keep multi-table design; every entity gains a userId partition; repository layer enforces tenant predicate deny-by-default; operator GSIs unreachable from app roles |
Smallest diff from the source repo's table-per-concern layout |
| AD4 | Scheduling | Replace global EventBridge cadences with a due-work index (DueWork{nextDueAt,userId}) scanned by a dispatcher fanning bounded batches into lane queues |
Per-user cadence replaces global tier intervals; fair-share becomes enforceable |
| AD5 | Settings | Per-user config documents in DynamoDB (JSON-Schema-validated, versioned); layering platform defaults < plan defaults < user config < saved-filter override. SSM /jobradar/* becomes platform-only |
Kills the source repo's single global namespace mutated by web_api/settings.py |
| AD6 | Secrets | Credentials table w/ KMS CMK + per-user data-key envelope encryption; server-side OAuth refresh; masked reads; never logged | Replaces single-slot SSM entries (Adzuna/Theirstack/PDL/Hunter/SAM/GitHub PAT, one Gmail refresh token) |
| AD7 | Email identity | Three stages: (1) send from product domain to user's inbox, (2) custom-FROM wizard using SES identities verified in our account, (3) BYO SES/SMTP creds for Pro | Sandbox→production path explicit; shared reputation protected by suppression lists + bounce/complaint handling from day 1 |
| AD8 | LLM safety | User-supplied rubric/profile text = untrusted input: delimited, output-validated, shadow-mode first, golden-set replay gate before activation; extend prompt-injection corpus | Source repo already has a 15-test injection suite and shadow-rubric machinery to build on |
| AD9 | Environments | Three AWS accounts (dev/stage/prod); IaC promotion; ephemeral control-plane stacks per PR | Mirrors the source repo's CI discipline |
Critical path: M0 → M1 → M2 → M4 → beta(M7/M8). M3/M5/M6 overlap M2–M4 where staffing allows. Sizes: S ≤ 1 wk · M ≈ 1–2 wk · L ≈ 2–4 wk · XL ≈ 4+ wk. Weeks are calendar estimates for one senior engineer plus part-time review, matching how the source repo is run.
- Name/legal: confirm "OpenJobRadar" (trademark search); pick license — AGPL-3.0 recommended if any engine code stays public alongside the SaaS; proprietary if not. Record it as this repo's first ADR either way.
- New repo, clean history: deliberately copy engine code from
jobradar; never copy.gitleaksignore,data/profile.yaml,registry.reviewed.csv, account IDs, or personal domains. The source repo keeps running untouched throughout. - Parity bridge: golden-set + recorded-fixture suites run in both repos until cutover; port the coverage ratchet, lint/type gates, axe + Lighthouse gates, and the prompt-injection corpus.
- CI skeleton: ruff/mypy/pytest/
cdk synthon PRs; branch protection onmain. - Exit: green CI · zero owner-identifying bytes in history · parity suite wired.
The heart of the conversion. Everything below exists today hardcoded or owner-shaped:
- Config schema v2 + validation layer (M1.1). JSON Schema for profile/strategy/policy docs;
validate on load and on write; schema versioning + migrations. Layered resolution extending
the source-tracking pattern from
src/common/config.pyto per-user documents. - Declarative location policy (M1.2). Replace the hardcoded metro allowlist, home-city
regexes, employer-based location exceptions, and per-city onsite-cadence carve-outs in
score/filters.pywith config-driven rules: metro list, radius, max-onsite-days, remote-only, timezone. Property-based tests (Hypothesis is already a dep). - Filter policy DSL (M1.3). Clearance stance, seniority bands, comp floor, excluded
archetypes/orgs, current-employer suppression → declarative config. Delete the hardcoded
current-employer default (
common/config.py:79). - Rubric-as-config (M1.4). Parameterize scorer prompts by profile facets; no person-specific
prose in source. Reuse
RUBRIC_VERSION, shadow-rubric mode, and the replay harness. Ship archetypes: IC-SWE, eng-leadership, PM, data, design, custom. - Values/exclusion presets (M1.5). Generalize
discover/exclusions.py+values_policy.yamlinto opt-in preset packs ("no defense/surveillance/incarceration", "climate only", "none") plus a user-extensible denylist. Defaults politically neutral; the packs carry the stances. - Purge sweep (M1.6).
applications/profile.py_PREFERENCESand its employer-specific confidentiality mirror inenrich/handler.py; scorer pronouns/background prose; infra constants (RECIPIENT_EMAIL,SENDER_DOMAINatinfra/lib/jobradar-stack.ts:116-122); README/docs voice. - Exit:
grep -rifor owner name / employers / metros / personal domains acrosssrc/returns nothing · pipeline green on ≥3 synthetic stranger profiles under the parity harness.
- Schema migration (M2.1). Add
userIdto every entity (Appendix B sketch); dual-write + backfill tooling; read-shims during transition. - Tenant-scoped repository (M2.2). Deny-by-default data access: no query without a tenant predicate; explicit context objects rather than ambient globals where feasible.
- AuthZ middleware + adversarial suite (M2.3). Every API route attempted cross-tenant must fail; fuzz the route×resource matrix; merge-gated like the coverage ratchet.
- Settings service (M2.4). Implements AD5; migrates everything
web_api/settings.pywrites to SSM today into per-user documents. - Credentials vault (M2.5). Implements AD6; rotation reminders; per-key health-check probes.
- Per-user budget ledger (M2.6). Port
common/budget.py's $12/day governor to per-user ledgers enforced before Bedrock invocation, under a platform-wide ceiling. - Threat model + DPIA refresh (M2.7). Extend the
docs/RESPONSIBLE-TECH-AUDITS.mdskeletons to consumer-product scope. - Exit: adversarial suite is red-on-mutation in CI · all reads/writes tenant-scoped · budget denial observable per user.
- Signup/signin/email-verify/password-reset/MFA-optional on Cognito pools; workspace = user (single-player v1; shared workspaces post-GA).
- Entitlements service + metering events (scores consumed, orgs watched, emails sent).
- Stripe integration: checkout, customer portal, webhooks → entitlement updates, dunning; downgrade lands in dry-run mode — never silent email death.
- Plan tiers (hypotheses; priced in §7): Free (dry-run only, 25 orgs, weekly digest) / Plus $15mo (live alerts, 300 orgs, extra channels) / Pro $30mo (BYO keys, custom rubric, custom sender domain, priority lane).
- Exit: plan changes flip entitlements within 60s · Free tier physically cannot incur more than ~$0.50/user/mo (cap enforced pre-invocation).
- Due-work scheduler per AD4 replacing global tier intervals;
poll_tiering.yamlsemantics move per-org and per-user. - Fair-share lanes: Free/Plus/Pro SQS lanes with per-user concurrency caps; noisy-neighbor load-shedding tests; scarcity-aware market scan runs per-user strategy.
- Tenant-aware notify: per-user thresholds (
ALERT_THRESHOLD/DIGEST_THRESHOLDsemantics), quiet hours, timezone, channel routing (common/channels.pygeneralized). - Outcome tracking (
source_yield.pyprovenance across ≥72 outcomes) keyed by user — the yield-adaptive polling loop must keep working when every org belongs to someone. - Exit: 200 synthetic tenants × heterogeneous configs soak-tested without cross-talk · p95 poll latency within 2× of the single-user baseline at beta scale.
Progressive disclosure: presets → forms → expert YAML escape hatch.
- Onboarding wizard (M5.1). Paste résumé or upload a LinkedIn export → LLM drafts profile + search strategy → confirm/edit → immediately see sample scored matches. Target: first dry-run digest preview ≤15 min from signup. Trust moment: everything DRY_RUN until explicitly armed.
- Settings hub (M5.2). The ~14 scattered YAMLs become grouped, validated sections: Alerts & thresholds · Location & remote · Seniority & comp · Values & exclusions · Channels · Models & budget. Every control states what it affects.
- Watchlist manager (M5.3). Add-org-by-URL via an interactive discover→ATS-detect funnel; tier toggles; bulk CSV import; dead-board healing surfaced with a re-resolve button.
- Preview & simulate (M5.4). Threshold what-if against stored history ("this would have been 4 alerts instead of 11 last week"); dry-run digest rendering; config-diff impact statements.
- Plain-language editing (M5.5, stretch). NL request → proposed validated config diff → review → apply. Human-in-the-loop; reuses Bedrock.
- Explainability everywhere (M5.6). Surface filter-drop reasons per posting ("hidden: location rule"); extends the why-this-scored work.
- Exit: five non-engineers complete onboarding unaided in usability tests · every setting has validation + impact text · no raw YAML required for full functionality.
Metering accuracy audits (reconcile metered events vs actual Bedrock usage), invoice emails, Stripe Tax, fraud controls (card-testing defenses, disposable-email gating on Free), refund/cancel runbook, revenue dashboard. Exit: month-close ledger reconciles within 1% of AWS bill attribution.
Implements AD7 stages 1–2.
- Product-domain sending with per-class configuration sets (alerts/digest/brief subdomains); bounce + complaint SNS handling → per-user suppression lists; SES production-access filing; DKIM/SPF/DMARC automation; staged warmup plan.
- Channel expansion beyond email: Slack/Discord/Telegram/webhooks, each with a test-send button.
- Digest schedule + quiet-hours UI.
- Exit: ≥98% inbox placement in seed tests across Gmail/Outlook/iCloud · complaint rate never above 0.1%.
- 20–40 friendly users running real searches; activation instrumentation end-to-end: signup → onboarding complete → first match → first alert → first application logged.
- Weekly friction triage; fix-or-kill the top issue each week.
- Pen test before widening; secret-rotation drill; backup/restore drill; incident runbook + status page.
- Load: 500 simulated users. Chaos: adapter malformed-response drills (port the fixture-refresh discipline so board-format drift fails CI, not silently returns zero).
- Exit: activation funnel ≥60% to first alert · zero open P1 security findings · support load ≤ 2 h/wk (sustainable solo).
Legal set live: ToS, privacy policy, subprocessor list (AWS/Stripe/Google), GDPR export/delete, EU AI Act transparency notes for consumer-facing AI scoring. Pricing page; Product Hunt/HN launch plan; community channels (GitHub Discussions for roadmap feedback even if the engine stays closed); post-GA backlog grooming (§5). Exit: GA definition-of-done checklist (§8) fully green.
| Workstream | Commitments |
|---|---|
| Security | Threat-model-as-merge-gate; least-privilege IAM per function audited quarterly; KMS key hygiene; no secrets in logs (enforced by test); pen test before beta widening and annually |
| Privacy | PII minimization (JD-archive bucket policy revisited for multi-tenant); per-user retention defaults; GDPR delete = crypto-erase of the user data key + row purge; DSR runbook |
| Cost safety | DRY_RUN default for every new user; caps enforced pre-invocation; per-user spend visible in-app; platform alarm on aggregate drift |
| Quality | Coverage ratchet carried over; adversarial tenant suite; property-based config fuzzing (malformed YAML fails closed with helpful errors, never half-applies); contract fixtures refreshed monthly |
| Observability | Tenant dimension on every EMF metric; per-user health view (port jobradar health checks: discovery / scoring / verdict-reachability / delivery / registry freshness); anomaly detection on per-user spend |
| Accessibility | Hold WCAG 2.2 AA + Lighthouse 100 on every new surface (the source repo's bar) |
| Ethics | Preset packs reviewed quarterly; screened-out-org audit log inspectable by the user who owns the screen |
Shared/team workspaces (recruiter/coach mode) · browser extension · VC-board connectors · webhook eventing · embeddings pre-filter rollout · company-enrichment cards · Enterprise tier (dedicated stack — Path-A packaging finally reused) · SOC 2 · mobile push · i18n (currently N/A by ADR in the source repo — revisit at public launch).
| Risk | Mitigation |
|---|---|
| Tenant-isolation bug leaks one user's data to another | Adversarial suite as a CI gate; deny-by-default repositories; threat model updated per feature; pen test pre-launch |
| One noisy/rogue user torches Bedrock spend | Pre-invocation per-user caps; platform ceiling; anomaly alarms; Free tier structurally capped |
| Prompt-injection via user-supplied rubric/profile | AD8: delimiting, output validation, shadow mode, injection-corpus extension, golden-set replay gate |
| SES reputation damage hurts every user | Suppression lists day 1; complaint-rate alarm at 0.1%; staged warmup; per-class config sets; BYO-SES escape hatch for high-volume Pro |
| "Customize everything" becomes unusable | Progressive disclosure (presets / forms / expert YAML); usability tests gate the M5 exit |
| Solo-operator on-call burnout | Incident runbook + status page before beta; sustainable-support exit criterion in M8; feature-freeze windows |
| Unit economics don't close | §7 hypotheses validated weekly in beta; pricing/entitlement knobs designed to flip without deploy |
| Two-repo transition drift | Parity bridge until cutover; source repo feature-frozen through M4 |
Per-user marginal cost, extrapolated from the source repo's measured single-user run (~$1–5/mo infra + Bedrock-dominated variable cost):
| Component | Free (capped) | Plus | Pro |
|---|---|---|---|
| Polling + storage | <$0.10 | <$0.25 | <$0.50 |
| Bedrock scoring (dominant driver: watchlist size × rescore policy) | ≤$0.50 (25 orgs, weekly digest only) | ~$1–3/mo (300 orgs, live scoring) | ~$3–10/mo (large watchlists, BYO keys option shifts cost off us) |
| Email + misc | <$0.05 | <$0.10 | <$0.20 |
| Total marginal | ≤$0.65 | ~$1.30–3.35 | ~$3.70–10.70 |
Gross-margin targets: Plus ≥ 75% · Pro ≥ 60%. Platform fixed costs (control plane, three AWS accounts, monitoring): target ≤ $150/mo under 100 users. Every number here is a hypothesis with a named owner metric in M8's instrumentation; pricing and entitlement knobs must be flippable without deploy.
- Zero personal facts in
src/(Appendix A checklist green) - Adversarial cross-tenant suite merge-gated and passing
- Per-user budgets enforced pre-invocation; platform ceiling alarmed
- Onboarding: ≥60% of beta users reach first alert unaided, ≤15 min to dry-run digest
- Settings hub covers all former YAML knobs with validation + impact text
- Billing reconciles within 1%; dunning → dry-run downgrade verified
- Deliverability: production SES access, suppression lists, ≥98% seed placement
- GDPR export/delete exercised end-to-end; DSR runbook tested
- Incident runbook + status page live; on-call rotation (solo-viable) defined
- Coverage ratchet, a11y, Lighthouse gates carried over and passing
- Legal set published (ToS, privacy, subprocessors, EU AI Act notes)
- Source repo either archived or clearly marked as the personal deployment
Source-repo references verified 2026-08-22.
| # | Item | Location in source repo | Done when |
|---|---|---|---|
| A1 | Candidate identity/profile | data/profile.yaml (+ profile.example.yaml) |
Schema v2 + validation; example profiles for tests only |
| A2 | Search strategy, values, model, tiering, rescore policies | data/*.yaml (14 files) |
All owner-shaped defaults neutralized; presets carry stances |
| A3 | Geography logic (metros, onsite cadence) | src/score/filters.py hardcoded metro/city regexes and employer location exceptions |
Config-driven rules; property-tested |
| A4 | Rubric prompt prose (pronouns, degrees, CA roots) | src/score/scorer.py |
Facet-parameterized templates |
| A5 | Targeting preferences + employer confidentiality rules | src/applications/profile.py _PREFERENCES; mirrored in src/enrich/handler.py |
Deleted; user-config equivalent shipped |
| A6 | Hardcoded current-employer default | src/common/config.py:79 |
Default empty; validated config field |
| A7 | Ethical exclusion defaults | src/discover/exclusions.py + data/values_policy.yaml |
Opt-in preset packs; neutral default |
| A8 | Recipient/sender identity + account ID | infra/lib/jobradar-stack.ts:116-122; README deploy section |
Fully parameterized CDK app |
| A9 | Global /jobradar/* settings namespace |
src/common/config.py, src/web_api/settings.py |
Platform-only SSM; per-user settings docs (AD5) |
| A10 | Single-slot integration credentials | SSM entries: Gmail OAuth token, Adzuna/Theirstack/PDL/Hunter/SAM/GitHub keys | Vault per AD6 |
| A11 | Owner-less tables | Seen, Postings, Subscriptions, Sends, Applications, Orgs | userId partitions everywhere (M2.1) |
| A12 | One-wallet cost controls ($12/day, weekend shares) | src/common/budget.py, data/rescore_policy.yaml |
Per-user ledgers + lanes (M2.6/M4) |
| A13 | Ops tooling bound to one stack (~130 scripts, cli/) |
repo-wide | Operator tooling behind admin auth; tenant-aware where user-facing |
| A14 | Shared registry artifacts | registry.reviewed.csv, known_careers.yaml, manual_boards.csv, Company_Follows.csv |
Rows keyed by user; files become import formats |
| A15 | Docs voice (first person, personal domain) | README.md and most of docs/ |
Rewritten handbook vs operator guide |
All tables gain userId = Cognito sub as partition key (or partition prefix). Sketch:
Users PK userId profile ref, plan, created, state
Settings PK userId SK configKey versioned JSON-schema'd documents
Orgs PK userId SK orgSlug watchlist/tier/ATS resolution per user
Seen PK userId SK stableId dedupe ledger
Postings PK userId SK stableId scores, verdicts, provenance
Subscriptions PK userId SK filterId saved filters / near-miss watches
Sends PK userId SK sendId send-audit (idempotency preserved)
Applications PK userId SK appId pipeline tracker
BudgetLedger PK userId SK date per-day spend caps + actuals
Credentials PK userId SK credId KMS envelope-encrypted BYO keys/OAuth tokens
Entitlements PK userId plan-derived limits, Stripe refs
DueWork GSI nextDueAt → userId, kind dispatcher scan index (AD4)
Operator/analytics GSIs are separate keyspaces reachable only from admin roles.
| Source asset | Disposition |
|---|---|
src/common/ (config, storage, http, budget, health) |
Adapt: config → layered per-user service; budget → per-user ledger; health checks ported to per-user view |
src/poll/ (50 ATS adapters + aggregators) |
Carry forward nearly verbatim; tenant context threaded through; adapters stay shared/global (board knowledge is universal) |
src/discover/ (resolve funnel, ATS detect) |
Carry forward; becomes interactive backend for the watchlist manager |
src/score/ (filters, scorer, jd archive) |
Heaviest rework: filters → DSL (M1.3), scorer prompts → facet-parameterized (M1.4); JD archive gets per-user retention policy |
src/notify/ (renderers, SES) |
Extend: channels beyond email, per-user quiet hours, suppression lists |
src/marketscan/, src/inbox/, src/outreach/, src/enrich/, src/applications/, src/trends/ |
Tenantize mechanically after M2; purge sweep items A5 |
web/ (Astro UI) |
Foundation for control-plane UI; settings hub + wizard added alongside existing matches/pipeline/trends pages |
infra/ (CDK TypeScript stack) |
Split into ControlPlaneStack + DataPlaneStack; fully parameterized (A8); dev/stage/prod promotion (AD9) |
cli/, scripts/ (~40 operator tools), bin/jobradar |
Stay operator-only; re-pointed at stage/prod accounts via admin role; never exposed to users |
tests/ (2,800+ tests, fixtures, injection corpus) |
Port harnesses first (parity bridge); extend with tenancy suites |
docs/ standards culture (ADRs, runbooks, audits) |
Adopt wholesale; first ADRs = AD1–AD9 above |
This roadmap is the working plan for this repository. Update it as milestones land; keep ADRs authoritative for decisions.