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 decisionsADR-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.
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.
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_accountsandseen_contactsledgers later moved to n8n Data Tables (the R2 refactor, cited in Sprint 6 - Guide.md). No ADR in the project records that move.
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.
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.
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.
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
accountsthrough 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 Websitebecame an HTTP Request node in R2 (Sprint 6 - Guide.md, project review F8). No ADR in the project records that change.
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.
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".
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.
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).
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_scoreis the highest contact score for the account. Each account is re-scored once per batch, andscore_breakdownis extended, never replaced.- Trade-offs
- Scores stay explainable across sprints.
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 grantsverified. - Trade-offs
- Email confidence stays low until verified emails exist. ADR-038 builds on this rule.
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
contactswith statusrejected. Theircontact_resultsrows 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.
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 outsidefirst_lineandtriggeris 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.
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 forNO_RESULTaccounts.
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.
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
opsrow 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.
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.
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_notesflags them. ADR-073 reaffirms this decision.
ADR-036autoMap allowed on single-writer outreach_queue
- Context
outreach_queuehas exactly one writer, and its headers match the router output exactly.- Decision
Append Outreach Queueuses Map Automatically.- Trade-offs
- Adding columns to the tab later means switching to manual mapping.
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_lineandbody_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.
ADR-038Channel follows email verification
- Context
- All 4 drafts went to the
emailchannel even though every email was only guessed. Guessed addresses bounce and hurt sender reputation. - Decision
- The channel is
emailonly whenemail_statusis verified or valid. Otherwise it islinkedin_manualif 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.
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_summaryor 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.
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 = supersededinstead of being deleted. - Trade-offs
- The queue keeps growing, so filter on
approval_status.
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.
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.
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_builtandquantified_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.
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.
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_logData Table keyed onoutreach_id:statusrecords what has been announced. The alternative was adigestedcolumn inoutreach_queue. - Trade-offs
- Avoids a second writer on the queue and lets a row be announced again once approved. One more table to maintain.
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.
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 Digestcode (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.
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_queuesheet. The alternative was Discord interactions. - Trade-offs
- One extra click. Digest v2 confirms that approval still happens in the sheet.
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.
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.httpRequestinside 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.
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.
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.
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.
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.
ADR-055Digest posts first, then logs
- Context
- A failed Discord post must never mark rows as announced.
- Decision
digest_logkeys 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.
ADR-056Outreach queue ledger is the source of truth
- Context
contacts.statuscan drift away from the queue (project review finding F7).- Decision
doneSet, built from the non-superseded rows ofoutreach_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.
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_runwrite 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.
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 = trueandmin_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.
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).
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_INACCESSIBLEon the Free plan (Sprint 7 Patch 2, section 6D). - Decision
- Keep
sources.apollo.enabled = falseuntil 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.
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 Sourcenode 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.
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
prioritywill be true for most YC accounts. A scoring recalibration needs its own sprint with before and after evidence.
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_keywordsterm as a whole word. The alternative was switching HN off. - Trade-offs
- HN volume drops to a handful of accounts a month.
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
industryandcompany_typecome from the query's own labels. The raw Apollo industry is kept after a|for audit.- Trade-offs
industryis our label, not a measured fact.
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.
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_resultstab 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_RESULTmarker plus a manual row is the fallback. No emails. Supersedes ADR-025. ADR-076 and ADR-077 amend it.
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.
ADR-068Manual run order, not one chained workflow
- Context
- A human must review the finder's
contact_resultsbefore 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.
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.
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.
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).
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.
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.
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 OutreachCode 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.
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.
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_lookupconfig switch turns it off, and anything unresolved falls through to Tavily.
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_unverifiedwhen 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.
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.
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 ignoredexports/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.
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.
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_atandreply_receivedin 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Job Radar
11 decisionsADR-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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
Wavess GTM platform (internship)
26 decisionsADR-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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
No decision matches this search and these filters.