Decision log

121 decisions from three projects, each with its context, the choice and what it cost. A superseded decision stays in the log and links to what replaced it.

Personal GTM Engine

84 decisions
  • ADR-001Separate from Job Radar
    Context
    Job Radar ranks job postings. The Personal GTM Engine works on companies, contacts and outreach plans, which is a different object model and a different goal.
    Decision
    The Personal GTM Engine is a standalone workflow and does not need Job Radar as an input.
    Trade-offs
    Cleaner portfolio and lower coupling. It needs its own discovery sources and config.

    Link to ADR-001

  • ADR-002n8n is the control plane
    Context
    The project has to show GTM engineering skill and keep control deterministic.
    Decision
    n8n owns orchestration, filtering, scoring, fallback routing and output. Clay is only used for enrichment.
    Trade-offs
    More build work in n8n, with control, logic and fallbacks in one place.

    Link to ADR-002

  • ADR-003Google Sheets as state layer
    Context
    v1 needs state that is free, easy to inspect and easy to review by hand.
    Decision
    Google Sheets holds config, output, dedupe and ops telemetry.
    Trade-offs
    Needs Execute Once and batch writes to stay inside quota. Note: the seen_accounts and seen_contacts ledgers later moved to n8n Data Tables (the R2 refactor, cited in Sprint 6 - Guide.md). No ADR in the project records that move.

    Link to ADR-003

  • ADR-004Deterministic gates before LLM and paid calls
    Context
    The project must cost $0 (NFR-01).
    Decision
    Hard filters (industry, timezone, headcount and the others) run before any enrichment, LLM call, Apollo credit or Clay credit. The project review restates the order for outreach: score, caps, channel rule, validator, then Groq, then Gemini, then the template.
    Trade-offs
    Some companies that look interesting but have incomplete data get dropped. More code, but spend stays at a predictable $0. The alternative was LLM-first.

    Link to ADR-004

  • ADR-005HITL before outreach
    Context
    Protect reputation and avoid email and platform risk. Reputation protection is a non-negotiable NFR.
    Decision
    The workflow drafts outreach but never sends it.
    Trade-offs
    Mohamed reviews the outreach queue by hand, a few minutes a day.

    Link to ADR-005

  • ADR-006Domain as account identity
    Context
    Company names are inconsistent across sources.
    Decision
    The normalized domain is the primary account key.
    Trade-offs
    Companies without a domain are rejected or marked for manual research.

    Link to ADR-006

  • ADR-013Enrichment as a post-write update pass with a two-output Code node
    Context
    Sprint 3 adds website enrichment without touching the proven gate and scoring core. In n8n, a branch with zero items stops the nodes after it.
    Decision
    Enrich rows that are already in accounts through an update-in-place pass. The Code node has a second output that always carries the stats package to Run Stats and Append Ops. The alternative was splitting Process Accounts into separate filter and score nodes.
    Trade-offs
    The core stays untouched and the ledger stays append-only. Note: later docs show the Enrich Companies node was deleted and Fetch Website became an HTTP Request node in R2 (Sprint 6 - Guide.md, project review F8). No ADR in the project records that change.

    Link to ADR-013

  • ADR-014Deterministic HTML signatures before LLM or paid enrichment
    Context
    Tech-stack and hiring signals were needed at $0.
    Decision
    Regex signatures on the raw website HTML detect CRM, automation, analytics and CMS tools. LLM website summaries are deferred to Sprint 6, per SPEC section 28.
    Trade-offs
    Costs $0, is explainable and can be cached.

    Link to ADR-014

  • ADR-015Clay v1 is a human-bridged table loop, not API
    Context
    On the free tier, Clay API access depends on the plan. The UI loop (tables, waterfalls, Claygent, credit economics) is also what interviews ask about.
    Decision
    Export from Sheets to a Clay table, then export back to Sheets (SPEC section 14, v1 usage). No API push or pull.
    Trade-offs
    One manual step per batch and zero spend. API automation was deferred to a Sprint 4 extension once credits are verified. The project review and the explainer repeat this decision as "Manual Clay hand-off".

    Link to ADR-015

  • ADR-016Clay merge is evidence-only, no final re-score yet
    Context
    The 5-component scoring formula in SPEC section 21 needs contact_feasibility, which arrives in Sprint 5.
    Decision
    The Clay merge writes evidence only: blank-fill, a tech-stack union, and a funding upgrade from unknown. It does not re-score the account.
    Trade-offs
    Re-scoring twice would break score explainability between sprints. The final re-score comes with ADR-021.

    Link to ADR-016

  • ADR-017Pilot-before-batch credit discipline
    Context
    Claygent cost is non-linear and cannot be seen before a run. The free tier is a hard limit (NFR-01).
    Decision
    No enrichment column runs on more than 2 rows until its per-row cost has been measured against the monthly free allowance.
    Trade-offs
    One extra manual step per batch, which is fine at v1 volumes (10 rows or fewer per run).

    Link to ADR-017

  • ADR-021Feasibility enters the account score as max contact_score
    Context
    Contact feasibility is the fifth component of the SPEC section 21 formula. Explainability (FR-11) has to survive weight changes between sprints.
    Decision
    contact_feasibility_score is the highest contact score for the account. Each account is re-scored once per batch, and score_breakdown is extended, never replaced.
    Trade-offs
    Scores stay explainable across sprints.

    Link to ADR-021

  • ADR-022Guessed is not verified
    Context
    SPEC section 25 compliance and protecting deliverability.
    Decision
    Emails that are pattern-based or not verified by the provider are capped at guessed (30 points). Only a waterfall verification flag grants verified.
    Trade-offs
    Email confidence stays low until verified emails exist. ADR-038 builds on this rule.

    Link to ADR-022

  • ADR-023Wrong contacts are rejected, not deleted
    Context
    The Contact Finder attached the wrong people to companies whose names are common words.
    Decision
    Wrong people stay in contacts with status rejected. Their contact_results rows are removed so the domain gets looked up again.
    Trade-offs
    A few dead rows in contacts, in exchange for a permanent guard against drafting to them again.

    Link to ADR-023

  • ADR-024Deterministic product-term guard before and after the LLM
    Context
    With only a homepage title and meta description to go on, the model mixed up what a company sells with what it struggles with. Drafts pitched a commission-software company commission automation, and pitched a CRM maker a CRM.
    Decision
    Fixed rules extract product terms: words the company repeats in its own description, minus filler and the sender's own vocabulary. The terms are passed to the model as PRODUCT_TERMS. Any draft that uses them outside first_line and trigger is rejected.
    Trade-offs
    A product described only once is missed, so human review stays the last gate. Lead-gen and enrichment vendors are not caught; that belongs to account filtering (ADR-078). Expect more first-attempt rejections and LLM calls. A second LLM call to judge relevance was rejected as costly and non-deterministic.

    Link to ADR-024

  • ADR-025Person discovery degrades to HITL sourcing when providers are plan-gated Superseded

    Superseded by ADR-066: Contacts come from a search API with human review, not a people database

    Context
    The Apollo and Datagma people providers in Clay both failed hard on the free tier (two refunded failures logged).
    Decision
    Machines verify, humans discover. Decision-makers are sourced by hand into a manual Clay table, then scored and deduplicated by n8n.
    Trade-offs
    Costs about 0 to 12 credits, but takes manual effort per company. Superseded when contact discovery moved to the Tavily search finder (ADR-066). Manual entry (hitl_manual) remains only as the fallback for NO_RESULT accounts.

    Link to ADR-025

  • ADR-026Size-aware wording
    Context
    Drafts said "your ops team" to companies of 11 and 14 people.
    Decision
    At 20 people or fewer, the validator rejects "ops team". Employee count is the only signal used.
    Trade-offs
    HN accounts default to 15 employees, so the rule applies to them even when the real team is bigger. The cost is slightly more personal wording, which is harmless.

    Link to ADR-026

  • ADR-033Outreach stage inside the main spine
    Context
    Sprint 6 adds outreach drafting to the engine.
    Decision
    Drafting runs as a stage inside the main workflow spine. The alternative was a separate workflow.
    Trade-offs
    Still one ops row per run, the stats-item pattern is reused and there is no cross-workflow state. Each main run gets about 5 to 25 s of LLM latency.

    Link to ADR-033

  • ADR-034LLM router as one Code node, config-driven chain
    Context
    Copy the quota-aware fallback from the wavess-core LLM router.
    Decision
    One Code node routes drafts through the provider chain and records failures per provider. The chain is set in config (outreach.llm.chain).
    Trade-offs
    Provider endpoints live in code. Models and chain order live in config.

    Link to ADR-034

  • ADR-035Template fallback always writes a row
    Context
    The HITL queue must never be empty without a visible reason.
    Decision
    When every LLM provider fails, a deterministic template draft is written with llm_provider = template.
    Trade-offs
    Template rows need a harder human review, and risk_notes flags them. ADR-073 reaffirms this decision.

    Link to ADR-035

  • ADR-036autoMap allowed on single-writer outreach_queue
    Context
    outreach_queue has exactly one writer, and its headers match the router output exactly.
    Decision
    Append Outreach Queue uses Map Automatically.
    Trade-offs
    Adding columns to the tab later means switching to manual mapping.

    Link to ADR-036

  • ADR-037Code assembles greeting, CTA and sign-off
    Context
    One draft had no greeting and another ended with [Your Name]. These are structural defects, so they need a structural fix.
    Decision
    The model writes only first_line and body_rest. Code adds the greeting, the exact CTA from config and the sign-off. The alternative was letting the model write the whole email.
    Trade-offs
    Every email shares the same skeleton.

    Link to ADR-037

  • ADR-038Channel follows email verification
    Context
    All 4 drafts went to the email channel even though every email was only guessed. Guessed addresses bounce and hurt sender reputation.
    Decision
    The channel is email only when email_status is verified or valid. Otherwise it is linkedin_manual if a LinkedIn URL exists, and otherwise the contact is skipped. The alternative was to email whenever an address exists.
    Trade-offs
    Few email drafts until verified emails exist.

    Link to ADR-038

  • ADR-039Validator plus tool whitelist before accepting any draft
    Context
    Prompts reduce hallucination but do not stop it. Drafts named tools that were not in the data (HubSpot for a company that does not use it, invented Stripe details).
    Decision
    A deterministic validator checks every draft: word counts, placeholders, numbers that are not in the facts, and company tools, which may only come from tech_stack, clay_summary or the chosen proof point. A failed draft falls through to the next provider. The alternative was trusting the prompt.
    Trade-offs
    More fallthroughs between providers. The template is the safety net.

    Link to ADR-039

  • ADR-040Supersede, do not delete
    Context
    Re-drafting needs an audit trail and should not require manual row surgery.
    Decision
    Replaced drafts are set to approval_status = superseded instead of being deleted.
    Trade-offs
    The queue keeps growing, so filter on approval_status.

    Link to ADR-040

  • ADR-041Router stays a Code node
    Context
    n8n's Basic LLM Chain has no fallback model (only the AI Agent node v2.1+ does), and the router needs custom validation between providers.
    Decision
    The router stays a Code node instead of a native LLM node with a fallback model. This corrects an earlier statement in the project.
    Trade-offs
    Provider URLs live in code.

    Link to ADR-041

  • ADR-042One repair retry before provider fallthrough
    Context
    The validator rejected 3 of 4 drafts on trivial rules that the model could fix (first_line_no_company, li_words).
    Decision
    A rejected answer is retried once on the same provider, with the rejection reason added to the prompt. The alternative was to fall through to the next provider immediately.
    Trade-offs
    Up to 2 calls per contact per provider, so tokens per minute matter. The 2.5 s pause between contacts stays.

    Link to ADR-042

  • ADR-043Claim guards in the validator
    Context
    Drafts described the sender's engine as the prospect's, claimed work that was never built, and invented quantified results.
    Decision
    The validator rejects wrong_perspective, overclaims_built and quantified_claim. The alternative was relying on human review alone.
    Trade-offs
    Regex rules are blunt and can reject a good draft, which then goes through repair or falls to the template.

    Link to ADR-043

  • ADR-044A separate digest workflow
    Context
    The digest runs on a different cadence (daily versus on demand), and a Discord outage must not affect discovery or drafting.
    Decision
    Personal GTM Digest is its own workflow. The alternative was appending the digest to the main engine.
    Trade-offs
    The Sheets reads are duplicated.

    Link to ADR-044

  • ADR-045digest_log Data Table keyed outreach_id:status
    Context
    Discord is not a database. Without a ledger, every run would post every pending row again.
    Decision
    A digest_log Data Table keyed on outreach_id:status records what has been announced. The alternative was a digested column in outreach_queue.
    Trade-offs
    Avoids a second writer on the queue and lets a row be announced again once approved. One more table to maintain.

    Link to ADR-045

  • ADR-046HTTP Request, not the Discord node
    Context
    The digest needs full control of embeds, size limits and allowed_mentions, plus retries and an error output.
    Decision
    Post to the Discord webhook with an HTTP Request node. The alternative was the native Discord node.
    Trade-offs
    The payload shape is ours to maintain.

    Link to ADR-046

  • ADR-047No emails in Discord
    Context
    Guessed emails are low quality, and Discord is a third-party store of personal data.
    Decision
    Digest cards show only the contact's name, title and LinkedIn URL.
    Trade-offs
    Open the sheet to see the email. Note: the Digest v2 Build Digest code (claude/Digest v2 - Full Review Card.md) prints the contact's email and email status in the Contact field when they exist. That contradicts this decision, and no ADR records the change.

    Link to ADR-047

  • ADR-048Approval happens in Sheets, not Discord buttons
    Context
    Discord buttons need a public endpoint and a bot application, which breaks the $0, no-exposure constraint.
    Decision
    Approval status is set in the outreach_queue sheet. The alternative was Discord interactions.
    Trade-offs
    One extra click. Digest v2 confirms that approval still happens in the sheet.

    Link to ADR-048

  • ADR-049Main engine stays manual
    Context
    Apollo's free tier returns 0 results, the Clay hand-off is a human step, and self-hosted schedules only run while the host is up.
    Decision
    The main engine has no schedule and is run by hand. The alternative was scheduling it like the digest.
    Trade-offs
    Someone has to click Execute. ADR-068 applies the same idea to the order of the three workflows.

    Link to ADR-049

  • ADR-050Apollo as a Code node with internal fetch
    Context
    A pluggable source must not break the Merge or the telemetry when Apollo fails.
    Decision
    Apollo is called from a Code node with this.helpers.httpRequest inside try/catch, the same pattern as the Job Radar connectors. The node always returns either data or a typed error marker. The alternative was an HTTP Request node with a separate error branch.
    Trade-offs
    The node always runs, so Merge and telemetry never break. ADR-065 keeps this pattern for all sources.

    Link to ADR-050

  • ADR-051Pluggable source contract
    Context
    Sprint 2 adds Apollo next to the seed list, and more sources are planned. This mirrors the Wavess/Oceanss connector interface.
    Decision
    A pluggable source is a normalized account schema, a config kill-switch and per-source telemetry. A new source only needs a mapper that outputs the normalized schema.
    Trade-offs
    Gates, scoring, the ledger and outputs stay untouched when sources are added.

    Link to ADR-051

  • ADR-052Deterministic country-to-UTC map instead of LLM geo inference
    Context
    The timezone gate needs an offset for each Apollo company at no cost.
    Decision
    A static country-to-UTC table maps the country to an offset.
    Trade-offs
    Costs nothing, is explainable and needs no cache. Countries not in the table fall through to the existing timezone gate.

    Link to ADR-052

  • ADR-053Waterfall over single provider for email finding
    Context
    Find as many emails as possible while keeping the cost per verified email low.
    Decision
    Email finding uses a cascade: Apollo, then Hunter, then an AI guess.
    Trade-offs
    In practice the Apollo API is blocked on the Free plan (ADR-060) and Tavily contacts are LinkedIn-only (ADR-070). The email waterfall now survives only as an optional Clay Work Email step.

    Link to ADR-053

  • ADR-054$env read in Code nodes
    Context
    API keys and the webhook URL must stay out of workflow JSON, so Code and expression nodes read them through $env.
    Decision
    Set N8N_BLOCK_ENV_ACCESS_IN_NODE=false. The alternative was a credential per node.
    Trade-offs
    Any Code node can read every environment variable, which is acceptable for single-user self-hosting. Since n8n 2.0 the default is true (Run Guide), so the setting must be checked after an upgrade or a container rebuild.

    Link to ADR-054

  • ADR-055Digest posts first, then logs
    Context
    A failed Discord post must never mark rows as announced.
    Decision
    digest_log keys are written only after a successful post. The alternative was logging first.
    Trade-offs
    A crash between the post and the log can repeat one digest. That was chosen over silently losing one.

    Link to ADR-055

  • ADR-056Outreach queue ledger is the source of truth
    Context
    contacts.status can drift away from the queue (project review finding F7).
    Decision
    doneSet, built from the non-superseded rows of outreach_queue, decides whether a contact can be drafted. The alternative was a status flag on the contact.
    Trade-offs
    The status column can drift without breaking dedupe.

    Link to ADR-056

  • ADR-057Remove the source-side cap, let gates and the write cap control volume
    Context
    The HN source stopped at 15 posts before checking the ledger, so every run read the same 15 posts again.
    Decision
    The HN cap comes from config (400) and covers 2 monthly threads. Volume is controlled by the gates and the max_companies_per_run write cap. The alternative was keeping 15 and rotating through the thread with a saved cursor.
    Trade-offs
    Stateless and easy to explain. Process Accounts re-checks rejected posts on every run, which costs CPU only. A "rejected" ledger can be added later if this gets slow.

    Link to ADR-057

  • ADR-058Allow unknown timezone and a score floor of 60, keep spending gates at 65
    Context
    Most HN posts state no timezone, and HN accounts cluster at scores of 58 to 64.
    Decision
    Set allow_unknown_timezone = true and min_account_score = 60. The contact, Clay and outreach gates stay at 65. The alternative was parsing timezones harder from HN text.
    Trade-offs
    More low-confidence rows in accounts. They cost nothing unless enrichment lifts them to 65 or more.

    Link to ADR-058

  • ADR-059No job-board APIs
    Context
    The pipeline needs a company domain. Arbeitnow's feed has no company website, and Remotive's free feed is a small, delayed sample with strict call limits.
    Decision
    Do not add job-board APIs (Remotive, Arbeitnow) in this patch. The alternative was adding a domain-resolution step, which needs its own refactor sprint.
    Trade-offs
    Discovery stays on HN and seeds, and later the YC directory (ADR-061).

    Link to ADR-059

  • ADR-060Apollo off until proven
    Context
    The Apollo request parameters did not match the docs, and the free credit allowance is small. Later, Apollo answered API_INACCESSIBLE on the Free plan (Sprint 7 Patch 2, section 6D).
    Decision
    Keep sources.apollo.enabled = false until a 1-credit pilot passes.
    Trade-offs
    HN and seeds carry discovery in the meantime. The fixed Apollo code stays in the workflow, switched off, for a future paid plan.

    Link to ADR-060

  • ADR-061YC directory as the primary free source
    Context
    HN gives no headcount or funding and only a weak GTM signal, so the automated accounts it produced were weak.
    Decision
    Add a YC Directory Source node that reads the free, keyless yc-oss JSON feed, which includes real headcount, location and topic tags. Alternatives considered: job-board APIs, paid data, more HN threads.
    Trade-offs
    It is an unofficial community feed that can break or lag. The pool is finite (about 6,270 companies, mostly US), and every company is YC-backed, which is a bias. The node fails soft with an error marker.

    Link to ADR-061

  • ADR-062Do not retune scoring in the discovery patch
    Context
    The scorer counts generic keywords, so curated sources score high across the board and the score stops ranking them well.
    Decision
    Leave scoring as it is. Remove generic YC tags from the description text and add a keyword gate at the source for HN.
    Trade-offs
    priority will be true for most YC accounts. A scoring recalibration needs its own sprint with before and after evidence.

    Link to ADR-062

  • ADR-063HN keeps running behind a strong-keyword gate
    Context
    HN is free and occasionally surfaces a real RevOps or GTM hiring signal, but most matches came from one generic keyword.
    Decision
    Keep the HN source, but require at least one strong_keywords term as a whole word. The alternative was switching HN off.
    Trade-offs
    HN volume drops to a handful of accounts a month.

    Link to ADR-063

  • ADR-064Apollo labels come from the query, not Apollo's industry field
    Context
    Apollo's industry names never match industries_include, so even a correct search would be rejected at the gate.
    Decision
    industry and company_type come from the query's own labels. The raw Apollo industry is kept after a | for audit.
    Trade-offs
    industry is our label, not a measured fact.

    Link to ADR-064

  • ADR-065Sources stay as Code nodes
    Context
    Three sources already share one marker and telemetry pattern, and changing that mid-patch adds risk without improving yield.
    Decision
    The discovery sources stay as Code nodes. The alternative was native HTTP Request plus Loop nodes.
    Trade-offs
    More code to maintain. It belongs in the planned sub-workflow refactor.

    Link to ADR-065

  • ADR-066Contacts come from a search API with human review, not a people database
    Context
    The Clay people finder is gated on the Free plan, Apollo People Search is blocked on Free, and Apify LinkedIn scrapers break LinkedIn's terms. The other option was a fully manual lookup.
    Decision
    Tavily search, restricted to linkedin.com and parsed with fixed rules, writes into the same contact_results tab the manual path uses. A human reviews the rows.
    Trade-offs
    Free and recurring (1,000 credits a month, no card), 1 credit per account, and no change to the main workflow. Coverage depends on the search index, titles can be stale, and small firms often return nothing; the NO_RESULT marker plus a manual row is the fallback. No emails. Supersedes ADR-025. ADR-076 and ADR-077 amend it.

    Link to ADR-066

  • ADR-067The contact finder is a separate workflow
    Context
    The hand-off tab (contact_results) already exists, and the main workflow is a tested 59-node spine.
    Decision
    GTM Contact Finder is its own workflow. The alternative was adding its nodes to the main workflow.
    Trade-offs
    Zero risk to the spine, and the finder can be re-run alone. There are now two workflows to run in order. Chaining them with an Execute Workflow node belongs in the sub-workflow refactor.

    Link to ADR-067

  • ADR-068Manual run order, not one chained workflow
    Context
    A human must review the finder's contact_results before the engine uses them.
    Decision
    Run the finder, the engine and the digest by hand, in that order. The alternative was an Execute Workflow node that chains them.
    Trade-offs
    Three clicks per cycle, and nothing happens while you are away.

    Link to ADR-068

  • ADR-069Clay is optional for a run
    Context
    The Clay Free plan covers about 20 to 30 rows a month, and discovery already produces more than that.
    Decision
    Contacts and drafts do not wait for Clay enrichment.
    Trade-offs
    Drafts for accounts without a Clay summary rely on the tech stack only and read more generic. Clay rows go first to the accounts getting contacts that day.

    Link to ADR-069

  • ADR-070Tavily contacts are LinkedIn-only
    Context
    The Tavily finder returns LinkedIn profile URLs, not emails.
    Decision
    Contacts found by the finder carry no email, so every new draft is linkedin_manual.
    Trade-offs
    An honest gap that keeps guessed emails out of the queue.

    Link to ADR-070

  • ADR-071Fix pacing before adding providers
    Context
    8 of 12 LLM calls failed. The cause was Groq's tokens-per-minute limit, and the circuit breaker turned one 429 into an outage that lasted the whole run.
    Decision
    First add pacing, retry-after handling, a cooldown that lets a provider come back, and lower reasoning effort. Extra model buckets are only the safety margin.
    Trade-offs
    Adding providers without pacing would only move the 429 around. Drafting gets slower (6 s between drafts, waits of up to 20 s within a 4-minute run budget).

    Link to ADR-071

  • ADR-072Three Groq models on one key instead of a third provider
    Context
    Mistral and Cerebras now need a card for API access, OpenRouter's free models allow only 50 requests a day, and Groq's limits are per model.
    Decision
    The chain is groq (gpt-oss-120b), groq:qwen/qwen3.8-27b, groq:openai/gpt-oss-20b, gemini:gemini-3.5-flash-lite, then the template.
    Trade-offs
    Triples capacity at no cost. One provider carries 3 of the 4 buckets, so a Groq outage leaves only Gemini before the template. The Qwen model is a preview and can be withdrawn; that shows up as a logged 404.

    Link to ADR-072

  • ADR-073Keep the template as the last resort
    Context
    When every LLM fails, the engine still has to produce a visible result.
    Decision
    Keep the deterministic template at the end of the chain. The alternative was skipping the contact. This reaffirms ADR-035.
    Trade-offs
    Every contact gets a row, and the failure is visible in telemetry. A template row marks the contact as used, so it has to be superseded to retry. The run budget handles slow providers without using up contacts.

    Link to ADR-073

  • ADR-074Homepage facts are fetched inside the Code node
    Context
    Without a Clay summary, the LLM invented details. One free GET of the homepage title and meta description gives it real facts.
    Decision
    The homepage fetch runs inside the Draft Outreach Code node, which goes against the "native nodes first" rule.
    Trade-offs
    No new nodes or wires in the 59-node workflow. More code in one node. It belongs in the sub-workflow refactor.

    Link to ADR-074

  • ADR-075One contact per company
    Context
    One run produced 3 drafts for one company and 2 for another. Several near-identical messages to a 15-person team on the same day look automated.
    Decision
    outreach.max_contacts_per_account = 1: only one live draft per company at a time.
    Trade-offs
    Fewer drafts per run. Rejecting a draft makes the next person at that company eligible.

    Link to ADR-075

  • ADR-076YC directory before search for founder lookup
    Context
    A semantic search on a company name cannot tell two companies with the same name apart. The finder picked the wrong people for five companies with common-word names, Twenty among them.
    Decision
    Read the public YC company page first, and accept it only when the website it lists equals the account domain. Rejected: Apollo or Clay people search (they cost credits) and LLM verification of each profile (cost, non-deterministic).
    Trade-offs
    Only covers YC companies, and it depends on YC's page markup. The yc_lookup config switch turns it off, and anything unresolved falls through to Tavily.

    Link to ADR-076

  • ADR-077Deterministic location gate, flag instead of drop on unknown
    Context
    Tavily name matches attached people from other companies and countries.
    Decision
    Reject a profile on a clear country mismatch, keep it on a match, and keep it as tavily_unverified when neither side states a location.
    Trade-offs
    Dropping unknowns would cut volume hard, since many snippets carry no location. Flagging keeps volume and moves the decision to HITL review.

    Link to ADR-077

  • ADR-078Competitors are filtered at the account stage, not the draft stage
    Context
    A workflow-engine company that competes with n8n scored 91 because its site mentions n8n.
    Decision
    Reject competitor accounts by setting their status, and exclude future ones with keywords_exclude ("workflow engine", "n8n alternative", "zapier alternative").
    Trade-offs
    The keyword list is short and maintained by hand. A competitor that describes itself differently still reaches HITL review.

    Link to ADR-078

  • ADR-079Public repo with sanitized exports
    Context
    The workflows are the portfolio evidence, but raw n8n exports carry the sheet ID, Data Table IDs, credential IDs, the instance ID and a personal email.
    Decision
    Commit only exports passed through scripts/sanitize_workflows.py, which replaces identifiers with placeholders and refuses to write if it finds a key, bearer token or Discord webhook URL. Raw exports live in an ignored exports/ folder.
    Trade-offs
    A reader must reconnect the sheet, tables and credentials after import (documented in SETUP.md). Live data (accounts, contacts, drafts) is never committed.

    Link to ADR-079

  • ADR-080Recorded walkthrough instead of a hosted demo
    Context
    The engine runs on a laptop and touches real API keys and real people's public data. Hosting a live demo would need an always-on server and a sandboxed data set.
    Decision
    Show the system in a short Loom recording plus README screenshots, with contact details kept out of frame.
    Trade-offs
    A reviewer cannot click through the live system. The repo plus SETUP.md lets anyone reproduce it at $0.

    Link to ADR-080

  • ADR-081Sending stays manual during dogfooding
    Context
    Free LinkedIn accounts allow only a few personalized connection notes per month, and automated LinkedIn activity breaks LinkedIn's terms (SPEC non-goal).
    Decision
    Send approved drafts by hand, record send_status, sent_at and reply_received in the sheet, and measure reply rate weekly.
    Trade-offs
    Volume stays low (about 10 to 20 touches a week). Each touch is reviewed and personal, which is the point of the engine.

    Link to ADR-081

  • ADR-082n8n stays the control plane, Clay is the enrichment worker
    Context
    Clay was used as a summary generator, with a hand-pasting step back into the sheet. Moving scoring and routing into Clay tables was the other way to go deeper.
    Decision
    Scoring, ledgers and human review stay in n8n. Clay does the work it is best at: structured research, email waterfalls and signals, on rows n8n has already selected.
    Trade-offs
    Two places to look when something breaks. The clay_ops meter and Clay's History keep the Clay side readable.

    Link to ADR-082

  • ADR-083One structured AI column per account
    Context
    Three separate web-research columns ran per account, each with its own model charge and Action.
    Decision
    One Use AI column returns a JSON object with fixed enums (summary, funding stage, tech stack, GTM signal, evidence URL), on the cheapest model that held up in a pilot. Formula columns split it out at no cost.
    Trade-offs
    A bad prompt now breaks four fields at once. A small pilot on every column before a batch catches that.

    Link to ADR-083

  • ADR-084Email waterfall gated by score, with Conservative validation
    Context
    Almost every contact was a LinkedIn profile with no email. Balanced validation would find more addresses.
    Decision
    Only accounts above the score threshold go to the Work Email waterfall. Validation is Conservative, so catch-all domains never count as found, and a guessed email is never marked verified without a validator check.
    Trade-offs
    A lower hit rate on small domains, accepted to protect the sender reputation.

    Link to ADR-084

  • ADR-085CSV contract and idempotent imports instead of webhooks
    Context
    Clay's webhooks and HTTP API column are trial or paid features. The old hand-off was a person pasting values into the sheet.
    Decision
    Clay exports to a raw tab, and n8n maps, validates and merges it by domain. Every import can run twice: the second run writes nothing new.
    Trade-offs
    One export click per batch.

    Link to ADR-085

  • ADR-086Spend the trial balance on purpose
    Context
    Clay forfeits trial credit above the rollover limit when the trial ends. Hoarding it loses it.
    Decision
    Budget the balance per module, pilot every column, read the meter before and after each batch, and keep only what rolls over.
    Trade-offs
    Needs the pilot discipline, so spending fast never means spending blind.

    Link to ADR-086

  • ADR-087One primary contact per account for the email waterfall, selected in n8n
    Context
    Most contacts without an email were second founders at companies already contacted. The engine drafts for one contact per account, so their emails would sit unused.
    Decision
    n8n queues one contact per qualified account: the person already contacted, otherwise the best new contact. Guessed emails go to validation only.
    Trade-offs
    Second founders stay LinkedIn-only until the contact policy changes. A careless run-all in Clay is guarded only by the run conditions.

    Link to ADR-087

  • ADR-S10-1Neon Free as the operating store
    Context
    Reviewing drafts in a spreadsheet was the bottleneck, and the review had to work from a phone without exposing the laptop that runs n8n. Alternatives: Supabase Free (pauses idle projects), Postgres in Docker on the laptop, or staying on Sheets.
    Decision
    Business data and review state move to Neon Postgres on the free plan, in the same region as the console. n8n only makes outbound calls to it.
    Trade-offs
    A vendor dependency, a storage cap and a short cold start after idle. The schema is plain Postgres, so a dump moves it anywhere.

    Link to ADR-S10-1

  • ADR-S10-2Strangler sync first, then cut over table by table
    Context
    The engine had about twenty Sheets nodes. Rewiring them all at once would break a working system for days.
    Decision
    A separate sync workflow mirrors Sheets into Neon and pushes console decisions back, so the engine runs unchanged on day one. Then config and the outreach queue move to Neon one at a time, each behind a reversible switch.
    Trade-offs
    A temporary extra workflow, and review decisions reach the sheet only on the next sync until the outreach cutover.

    Link to ADR-S10-2

  • ADR-S10-3Field ownership and versioned dirty flags
    Context
    Two writers (the engine and the console) share the same rows. Last write wins would lose approvals.
    Decision
    Each table names who owns each field. A console write stamps a version; the sync clears it only if the stamp is unchanged, so a change made during a sync is pushed on the next run instead of being marked done.
    Trade-offs
    More SQL than a plain copy, in exchange for a race that was tested and does not lose a decision.

    Link to ADR-S10-3

  • ADR-S10-4Review rules live in Postgres functions
    Context
    The rules could live in the console's server code instead.
    Decision
    One PL/pgSQL function holds the approval state machine. The console, n8n and the SQL editor all go through it, and every action writes an audit row with the values before and after. Invalid transitions are refused by the database.
    Trade-offs
    PL/pgSQL to maintain, and errors surface as strings. In return the rules are tested on their own and the UI cannot bypass them.

    Link to ADR-S10-4

  • ADR-S10-5Raw SQL over HTTP, no ORM
    Context
    The schema is shared with n8n and owned by one SQL file. Drizzle, Prisma or a pooled driver were the alternatives.
    Decision
    The console reads views and writes through functions, one statement per call, with the Neon HTTP driver and parameterized queries only.
    Trade-offs
    Hand-written row types and one HTTPS round trip per query. A pooled driver comes back only if a page ever needs a multi-statement transaction.

    Link to ADR-S10-5

  • ADR-S10-6Single-owner password and a signed cookie
    Context
    The console holds prospect names and emails and has one user. Hosted auth or Vercel's deployment protection were the alternatives; on the free plan the latter leaves the production domain public.
    Decision
    A password gate with a signed HttpOnly session cookie, checked in every page and server action, not only in the redirect layer. The site is noindex and has no public API routes.
    Trade-offs
    No second factor. Rotating the session secret logs out everywhere.

    Link to ADR-S10-6

  • ADR-S10-9Approve-to-send stays out for now
    Context
    With the console in place, an approval could send the message directly.
    Decision
    Sending stays manual and is recorded with mark sent. Automated sending waits until the review loop has run for a while, and needs its own design: a suppression list, a country gate, verified emails only and a daily cap enforced in SQL.
    Trade-offs
    Manual sending continues. The audit log already proves which drafts were approved.

    Link to ADR-S10-9

  • ADR-S11-1A separate workflow imports the Clay triggers
    Context
    The existing Clay merge skips accounts it has already enriched, by design, so it cannot carry new trigger columns for them.
    Decision
    A small manual workflow writes the trigger, its source and its date onto the accounts tab, matched by domain. Re-running it rewrites the same values.
    Trade-offs
    One more small workflow, instead of changing a merge rule and risking a re-merge of earlier Clay data.

    Link to ADR-S11-1

  • ADR-S11-2The trigger is stored on the account
    Context
    A separate signals table in Postgres would keep a history of triggers.
    Decision
    Three text columns on accounts, owned by the engine and mirrored like every other account column. The console already joins accounts, so no new sync path or ownership rule was needed.
    Trade-offs
    One trigger per account and no history.

    Link to ADR-S11-2

  • ADR-S11-3Hiring, then funding, then news
    Context
    Clay could return several signals per account, and an earlier AI column produced an undated GTM signal that read like an inference.
    Decision
    One fact per account: a recent relevant job opening first, then a recent funding round, then recent news. The undated AI signal is not used as a trigger.
    Trade-offs
    Weaker signals are hidden when a stronger one exists, and fewer accounts get a trigger. Prompts stay short and the validator rule stays simple.

    Link to ADR-S11-3

  • ADR-S11-4Trigger date stored as text
    Context
    An empty cell from Sheets arrives as an empty string, which a date column rejects. One bad cell would abort the whole sync.
    Decision
    The trigger date is a text column holding an ISO date or nothing.
    Trade-offs
    Sorting by date needs a cast.

    Link to ADR-S11-4

  • ADR-S11-7The data adapts to the validator, not the other way around
    Context
    The drafting validator rejects any currency or percent sign and any link, so a funding trigger with an amount in it would push every draft to the template.
    Decision
    The trigger import rewrites currency signs as a number and a currency code, and strips links, before the trigger reaches the engine. The validator stays as strict as it was.
    Trade-offs
    Amounts in triggers read as a number and a currency code instead of with a currency sign.

    Link to ADR-S11-7

Job Radar

11 decisions
  • ADR-JR-01Router inlined in the main workflow
    Context
    The n8n build in use had no sub-workflow trigger.
    Decision
    Keep the LLM router inside the single Job Radar workflow. Extraction into a shared component is deferred to the next project.
    Trade-offs
    One large workflow; the router pattern is reused by copy in the Personal GTM Engine.

    Link to ADR-JR-01

  • ADR-JR-02LLM router as a single Code node
    Context
    The HTTP Request node could not place a JSON response in a field, and fetch-based calls failed in the sandbox.
    Decision
    One Code node calls providers through this.helpers.httpRequest, a direct port of the router pattern from the Wavess internship. Temperature is passed only when numeric.
    Trade-offs
    Logic lives in code rather than visible nodes, so it needs its own telemetry (provider and errors per item).

    Link to ADR-JR-02

  • ADR-JR-03Service account and environment variables for credentials
    Context
    Google OAuth tokens in testing mode expire after 7 days.
    Decision
    Use a Google service account for Sheets and environment variables for LLM and Discord keys.
    Trade-offs
    The sheet must be shared with the service account e-mail; env changes need a container restart.

    Link to ADR-JR-03

  • ADR-JR-04Seen-URL ledger updates in parallel
    Context
    If no job passes the gates, a serial branch would never record what was already seen.
    Decision
    Branch the seen-URL write off the batch slice so it runs even when zero matches are written.
    Trade-offs
    Two writes per run instead of one.

    Link to ADR-JR-04

  • ADR-JR-08Dynamic config in a sheet tab
    Context
    Thresholds and keyword lists changed weekly during tuning.
    Decision
    A key/value config tab, parsed by a Load Config node with typed coercion, defaults and a warnings list.
    Trade-offs
    Every behavior change is a sheet edit; a bad value falls back to its default with a warning instead of breaking the run.

    Link to ADR-JR-08

  • ADR-JR-09Per-source filters before the merge
    Context
    Each source has its own noise (disclaimers, region-locked roles, "for hire" posts).
    Decision
    Apply deterministic per-source vetoes in each mapper before the merge.
    Trade-offs
    More mapper code, but the shared gates only see normalized, plausible items.

    Link to ADR-JR-09

  • ADR-JR-10Empty-run sentinel
    Context
    An empty output halts downstream n8n nodes, so telemetry and the digest would not run.
    Decision
    The scoring node emits a sentinel item when there are no matches; a Filter node drops it before the sheet write.
    Trade-offs
    One extra node; telemetry is written on every run.

    Link to ADR-JR-10

  • ADR-JR-11Visa filter moved from API query to config
    Context
    The visa restriction for one source was applied as an API query parameter, outside the config.
    Decision
    Fetch everything and apply the visa restriction as a config gate.
    Trade-offs
    Slightly more data fetched; the filter is visible in config and can be switched off.

    Link to ADR-JR-11

  • ADR-JR-14Rate-limit guardrails for a fragile public source
    Context
    A public JSON API behind a web application firewall returned 429s.
    Decision
    One request per day, an identified user agent, continue-on-error, and a config kill switch.
    Trade-offs
    The source can disappear for a day without failing the run.

    Link to ADR-JR-14

  • ADR-JR-16GCP Always Free VM for deployment
    Context
    The pipeline must run daily at $0 without the laptop on.
    Decision
    Docker on a GCP e2-micro Always Free VM, port bound to localhost and reached through an SSH tunnel, with a $1 budget alert as an alarm.
    Trade-offs
    Small VM (swap required); no public UI exposure by design.

    Link to ADR-JR-16

  • ADR-JR-17Calibration as data
    Context
    Output quality tuning tempted code edits.
    Decision
    All quality tuning happens in the config tab with a rev bump: one variable per change, one ops row as evidence.
    Trade-offs
    Slower tuning, but every change is traceable to a run.

    Link to ADR-JR-17

Wavess GTM platform (internship)

26 decisions
  • ADR-WV-01Federated services, not a monolith or microservices
    Context
    Content generation and signal collection have different workloads, data models and release rhythms. The team was small, and hosting on Fly.io came with tight memory and cost limits.
    Decision
    Three coarse-grained services (Portal, Tropicc, Oceanss), each with its own schema and its own deployment, on one shared library. A monolith was rejected because one deployment cannot scale content generation apart from signal collection, and a shared schema would tie the two data models together. Fine-grained microservices were rejected because service-to-service communication, distributed transactions and separate databases were more than a small team could run.
    Trade-offs
    Moderate operational overhead: three deployments, and a shared library that all three have to stay in step with.

    Link to ADR-WV-01

  • ADR-WV-02The Portal is its own service
    Context
    Identity and workspace governance need stricter access control and a slower release rhythm than the product services. A breach or an outage in identity blocks every product behind it.
    Decision
    The Portal is a separate service that owns sign-up, organizations, roles, subscriptions and product access. Tropicc and Oceanss depend on the identity state it establishes.
    Trade-offs
    A single point of entry: when the Portal is down, nobody can log in to either product.

    Link to ADR-WV-02

  • ADR-WV-03Content and GTM intelligence stay separate services
    Context
    Tropicc manages drafts, schedules and LinkedIn tokens. Oceanss manages signals, scores and competitor watchlists. They use different AI pipelines, different data models and serve different clients.
    Decision
    Two services. Merging them would create one data model full of unrelated entities and force both domains onto one deployment lifecycle despite different performance profiles.
    Trade-offs
    Signals from Oceanss do not reach Tropicc on their own. Passing intelligence from one service to the other is still future work.

    Link to ADR-WV-03

  • ADR-WV-04A shared library, not an API gateway
    Context
    Token verification, tenant resolution and LLM routing are needed on every request in every service. As the first service matured, this code had ended up duplicated or scattered.
    Decision
    Wavess-Core is a Python library that each service imports, not a gateway or a sidecar. These concerns sit inside each service's request lifecycle, and a separate network hop for them was not justified. Each service installs the library from Git, pinned to a commit, so builds are reproducible. Platform mechanics stay in the library; business logic stays in the services.
    Trade-offs
    The library is not deployed on its own: a fix reaches a service only when that service moves its pin and redeploys.

    Link to ADR-WV-04

  • ADR-WV-05One database, one schema per service
    Context
    Every record belongs to an organization, and three services need the same identity data without reaching into each other's tables.
    Decision
    One Supabase PostgreSQL instance. The public schema holds organizations and memberships; Tropicc and Oceanss each have their own schema. The organization is the tenant anchor for every user, configuration, generated artifact and report.
    Trade-offs
    The services still share one database instance.

    Link to ADR-WV-05

  • ADR-WV-06Supabase for data and identity
    Context
    The platform needs relational entities, row-level security, schema isolation and authentication, on a startup's budget.
    Decision
    Supabase: PostgreSQL with native row-level security, schemas for isolation and built-in authentication in one managed service with a free tier. Firebase and DynamoDB were rejected for their NoSQL data model, which cannot represent the relational multi-tenant domain. Self-hosted PostgreSQL meant a server to pay for and security to set up by hand.
    Trade-offs
    Its storage did not cover optimized image delivery, so content images go to a second service.

    Link to ADR-WV-06

  • ADR-WV-07FastAPI for every service
    Context
    The requirements called for asynchronous execution, typed request validation and one shared library across services. The AI and ML dependencies were already in Python.
    Decision
    FastAPI for Portal, Tropicc and Oceanss: native async, validation through Pydantic, and generated OpenAPI documentation that keeps the interfaces between services explicit. Express was rejected because the Python dependency chain made a Python runtime mandatory. Django needed an add-on for async and carries more startup overhead.
    Trade-offs
    The whole platform is tied to the Python runtime.

    Link to ADR-WV-07

  • ADR-WV-08Server-rendered pages with HTMX, no single-page app
    Context
    A small team had to ship forms, dashboards, review screens and onboarding within the sprint rhythm. None of these screens needed single-page-app interactivity.
    Decision
    Jinja2 templates with Bootstrap, and HTMX for partial page updates. No frontend build chain and no JavaScript framework. React and Vue were rejected for the build tooling and state management they add.
    Trade-offs
    Partial updates only: a screen that needs full client-side interactivity would not fit this approach.

    Link to ADR-WV-08

  • ADR-WV-09ARQ and Redis for background jobs
    Context
    Generation, scraping and signal collection run too long for a web request, and the machines are small and suspend when idle.
    Decision
    ARQ on Upstash Redis: asynchronous Python jobs with Redis as the only extra infrastructure. Celery with RabbitMQ needed a broker process that is complex to deploy on Fly.io. RQ lacked the job lifecycle hooks and the cron scheduling the automation scheduler needs.
    Trade-offs
    One Redis instance now carries the job queues, the LLM quota counters and the scraping cache.

    Link to ADR-WV-09

  • ADR-WV-10Fly.io with suspend-on-idle
    Context
    Three services had to deploy independently and stay cheap to run.
    Decision
    Each service is a Docker container deployed as its own Fly.io application and suspended when idle, so nothing is billed while it sleeps. AWS ECS was rejected for its operational overhead and pricing complexity, a VPS for its fixed monthly cost and the system administration it needs. Heroku has no suspend-on-idle.
    Trade-offs
    Small machines: the memory ceiling shaped other choices, such as the lightweight job queue and loading ML models lazily.

    Link to ADR-WV-10

  • ADR-WV-11One LLM router, many providers
    Context
    A bootstrapped startup cannot pay per token for every call, or depend on one vendor staying up. The cost requirement set a goal of serving more than 90% of requests within free tiers.
    Decision
    Every LLM call from every service goes through one router in the shared library, with routes across Groq, Cerebras, Gemini and NVIDIA NIM. Daily usage per provider is counted in Redis. A provider is skipped at 95% of its daily cap, before it starts returning rate-limit errors, with automatic fallback to the next one. A single paid vendor was rejected as a single point of failure with no free tier, and self-hosted models for their GPU cost.
    Trade-offs
    The free-tier goal is a design target; the report does not measure it. When every provider in a chain is exhausted, the request fails with a 503.

    Link to ADR-WV-11

  • ADR-WV-12Route by task and size, count only successful calls
    Context
    A classification call, a long document and a general request suit different models. A failed call should not eat into a provider's daily budget.
    Decision
    The router picks one of three paths from two fields of the request, the estimated token count and the task type: long context, classification or general. Quota counters increase only on successful responses, so failed attempts, network errors and rate-limited retries do not consume the budget.
    Trade-offs
    Routing depends on a token estimate made before the call. The last-resort provider is called without a quota check.

    Link to ADR-WV-12

  • ADR-WV-13Cache the signing keys, refresh on an unknown key
    Context
    Every authenticated request verifies a token against the identity provider's public keys. Fetching those keys each time would add a remote call to every request.
    Decision
    The middleware keeps the key set in memory for one hour and fetches it again only when a token names a key it does not know, which is what a key rotation looks like.
    Trade-offs
    The cache lives in the process: each service instance fetches and holds its own copy.

    Link to ADR-WV-13

  • ADR-WV-14Three gates before a product opens
    Context
    An organization can be provisioned for one product and not the other, a subscription can lapse, and an admin may want to give a product to some team members only.
    Decision
    Product access is checked in three layers: the organization is provisioned for the product, its subscription is active or in trial, and the user has been granted the product. Roles (owner, admin, member) apply on top, and a separate platform-admin flag gives internal staff cross-organization visibility for support.
    Trade-offs
    More to keep right: access has to be granted at the organization level and at the user level before anyone gets in.

    Link to ADR-WV-14

  • ADR-WV-15Write the job record first, then enqueue its ID
    Context
    Scans and generation jobs outlive the request that started them, and the user needs to see progress and a result.
    Decision
    The service first writes a database record with a pending status, then enqueues the task with that record's ID as its only argument. The route answers at once and the worker updates the record when it finishes. Each stage of a long pipeline has its own timeout, and the pipeline checks for a cancel flag between stages.
    Trade-offs
    A cancelled pipeline stops at the next stage boundary, not immediately.

    Link to ADR-WV-15

  • ADR-WV-16Hour-long scans get their own machine
    Context
    The hiring intent scan can run for up to an hour. Signal collection and the scheduler are short jobs.
    Decision
    The intent scan worker runs as a dedicated Fly.io application, so it cannot compete with web requests for CPU and memory. The signal collection and scheduler workers run as process groups on the web application.
    Trade-offs
    One more application to deploy and monitor.

    Link to ADR-WV-16

  • ADR-WV-17Onboarding writes a draft, the user confirms
    Context
    Onboarding fills a company profile from the organization's website with an LLM. It had to be revised after seeing what extracted profiles looked like in real use.
    Decision
    Extraction results go into a draft column and never straight into the organization's record. The user reviews, corrects and confirms, and only then is the draft promoted. A later re-extraction shows a difference per field and the user selects each change. Background enrichment fills blank fields only, so nothing the user typed is overwritten.
    Trade-offs
    One more step for the user before onboarding is finished.

    Link to ADR-WV-17

  • ADR-WV-18Create the organization atomically, undo the user if it fails
    Context
    Sign-up creates an authentication user and an organization. If the second step fails, the first leaves an orphaned account that blocks a retry with the same e-mail.
    Decision
    A duplicate-organization check runs before any identity record is created. A stored procedure inserts the organization and the membership in one transaction; if it fails, the service deletes the authentication user through the admin API so the person can try again.
    Trade-offs
    The undo is a compensating step, not part of the transaction: the authentication user lives in a different system from the tables.

    Link to ADR-WV-18

  • ADR-WV-19One login across products through a parent-domain cookie
    Context
    Portal, Tropicc and Oceanss live on separate subdomains and have to share one session.
    Decision
    The Portal sets the session in a cookie scoped to the parent domain, and the other services verify the same token through the shared middleware. Redirect targets are checked against an allowlist.
    Trade-offs
    Staging and local hosts get host-scoped cookies, so single sign-on across products exists only in production.

    Link to ADR-WV-19

  • ADR-WV-20Retrieval only where facts matter
    Context
    A post about a client's product has to state facts about that product, and a model's memory is not a source for those.
    Decision
    Retrieval-augmented generation is a bounded capability, not a platform default. It grounds product posts in documents the client uploaded, stored per organization, and the interface shows the sources used. Embeddings are computed locally, which avoids a per-request API cost.
    Trade-offs
    The other generation flows are not grounded in retrieved documents.

    Link to ADR-WV-20

  • ADR-WV-21Refactor parity before coverage
    Context
    Most of the content service was rewritten during the first phase, while it was the only product.
    Decision
    The test suite for the rebuild asserts that each refactored function behaves exactly like its legacy counterpart, so the decomposition could not drift. External services are mocked.
    Trade-offs
    The suite proves that the rewrite matches the old behavior. It does not say how much of the code it covers.

    Link to ADR-WV-21

  • ADR-WV-22Collect only the signals the client's goal needs
    Context
    Each signal source costs API calls, and each organization picks one business goal.
    Decision
    A goal-to-signal map restricts collection to the signal types that support the organization's selected goal. The dashboard follows the same rule: it queries and renders only the sections of the active goal.
    Trade-offs
    An organization sees nothing outside its goal.

    Link to ADR-WV-22

  • ADR-WV-23Alerts measured against each company's own baseline
    Context
    Tracked companies differ widely in how active they are, so one fixed threshold cannot fit all of them.
    Decision
    Anomaly detection uses EWMA and CUSUM on weekly windows, with triggers defined relative to the computed baseline instead of absolute values. Spans are shorter for volatile hiring signals and longer for media coverage. Newly tracked companies with a zero baseline get special handling.
    Trade-offs
    Detection sensitivity has to be tuned for each signal type.

    Link to ADR-WV-23

  • ADR-WV-24Six checks before an automated scan
    Context
    Scans spend scraping and LLM budget, and some organizations stop logging in.
    Decision
    Before a scan is enqueued, six checks must pass: the weekly budget, one scan per day, the goal has signals mapped, the organization is not inactive, automation is enabled and no scan is already running. Activity tiers slow scans down for dormant organizations and suspend them for inactive ones.
    Trade-offs
    Only customer-profile scans run on the weekly schedule. Competitor scans are still triggered by hand from the admin interface.

    Link to ADR-WV-24

  • ADR-WV-25Dashboard queries are not cached
    Context
    Intelligence data changes often, and a stale dashboard can mislead the person deciding which accounts to pursue.
    Decision
    The client dashboard recomputes its goal data on every request, with no caching layer. Each goal's helper runs in its own exception handler, so one failing section does not take the page down.
    Trade-offs
    Every request pays the full query cost, and the work runs serially.

    Link to ADR-WV-25

  • ADR-WV-26A weighted score for priorities
    Context
    The backlog was larger than the internship, and the order of work had to be explainable, not a matter of taste.
    Decision
    Each requirement gets a score from four criteria rated 1 to 5: business value (weight 0.40), client urgency (0.25), technical risk reduction (0.20) and architectural dependency (0.15). A score of 4.0 or more is high priority and blocks the MVP. I scored the requirements and the CEO reviewed the scores in sprint planning.
    Trade-offs
    One person scored and one reviewed: there was no formal inter-rater validation.

    Link to ADR-WV-26