Why this exists, and why not just SQLite
An earlier project (gtm-warehouse-demo) built a four-table SQLite warehouse with hand-written SQL. This repo replaces that with the tool GTM Engineer postings actually name: dbt (staging → marts, tests, docs, incremental models) over an analytical engine — DuckDB here, though the same dbt project would point at BigQuery or Snowflake with a one-line profile change and no model changes. That portability is the reason dbt exists.
What's real, what's synthetic
Read this before citing any number from this repo. Signal data is real. Touch and opportunity data is synthetic, generated because real sequencing infrastructure doesn't exist yet in this portfolio. Reply rates, meeting counts and anything downstream of them are demonstrations of the warehouse's shape, not real outcomes.
| Data | Status | Source |
|---|---|---|
| raw_fda_signals.csv — 34 firms | REAL | Extracted from n8n-gtm-orchestration's committed native execution record |
| synthetic_touches.csv — 15 touches | SYNTHETIC | Generated, fixed seed 20260823 |
| synthetic_opportunities.csv — 2 opps | SYNTHETIC | Generated, fixed seed, reply probability weighted by score, capped 35% |
Two scripts make the boundary reproducible rather than asserted — both regenerate the committed seeds byte-for-byte:
python scripts/extract_real_signals.py # regenerates the REAL seed from the n8n repo python scripts/generate_synthetic_seeds.py # regenerates the SYNTHETIC seeds, same fixed seed
The model
seeds (raw_fda_signals REAL, synthetic_touches/opportunities SYNTHETIC)
│
▼
stg_fda_signals · stg_touches · stg_opportunities (staging: cast, rename, clean)
│
▼
dim_account one row per firm — real signal columns, synthetic touch/opp counts
fact_signal incremental — real, one row per (firm, batch_date)
fact_touch synthetic
fact_opportunity synthetic
funnel_summary signal counts real, downstream stages synthetic
fact_signal is the incremental model. Only one signal batch exists today, so an incremental run and a full-refresh currently produce the same table — the pattern is demonstrated at the scale actually available, not dressed up to look bigger. In production, each scheduled n8n run would append a new batch, and is_incremental() means this model only scores what's new instead of reprocessing history on every run.
Deliberately does not re-implement multi-touch attribution. That already exists, built properly, in revenue-engine-system (4 models compared on its own disclosed data). Duplicating it here, on a smaller and mostly-synthetic sample, would be the same claim made twice, once weaker.
Tests — 46/46 passing, zero warnings
export DBT_PROFILES_DIR="$PWD" dbt build
Finished running 1 incremental model, 3 seeds, 4 table models, 35 data tests, 3 view models in 1.06s Done. PASS=46 WARN=0 ERROR=0 SKIP=0 NO-OP=0 TOTAL=46
Full run log committed at output/dbt-build-log.txt. 35 data tests: the standard set (unique, not_null, relationships, accepted_values) plus two written for this project:
macros/test_value_in_range.sql— a custom generic test, written instead of pulling indbt_utilsso this project has no external dependency to install or version-pin. Boundssignal_scoreto [0, 100].tests/assert_briefed_firms_cleared_threshold.sql— a singular test, and a real cross-artifact check: n8n's "score ≥ 60 AND has recall lines" filter is what decides which firms get briefed. If a firm is markedwas_briefedhere with a score under 60, either this warehouse mis-copied the source, or the upstream n8n filter changed without this model knowing.
All seven MissingArgumentsPropertyInGenericTestDeprecation warnings dbt raised on the first build were fixed (test arguments moved under the current arguments: nesting) rather than left as noise. dbt build now runs clean.
Source freshness — specified, not faked
models/staging/_staging.yml documents a freshness: block on the raw_fda_signals source, commented out with an explanation: freshness compares a source's last-loaded timestamp against a schedule, and this project's data arrives as a committed seed (for reproducibility), not a live nightly load. Turning that check on for real is exactly the config shown, uncommented, the day this table is fed by a schedule instead of a seed. Writing a check that would always silently pass would be worse than not having one.
Reproduce it
python3 -m venv .venv ./.venv/bin/pip install dbt-core dbt-duckdb export DBT_PROFILES_DIR="$PWD" ./.venv/bin/dbt build dbt docs generate && dbt docs serve # interactive lineage DAG in a browser
Tested with dbt-core 1.10.23 and dbt-duckdb 1.10.0 on Python 3.9.6 (macOS system Python). No API key, no cloud account, no cost.
What this doesn't prove
- The synthetic touches and opportunities are not real outbound activity. No email has been sent, no reply received. The reply-probability weighting is a modeling choice, not a measurement.
- One batch date does not prove the incremental model handles genuinely new data across multiple real runs — it proves
is_incremental()is present and produces the same result as full-refresh on the data that exists. The real test happens the second time n8n produces a new batch. funnel_summary'stouch_to_meeting_pct_syntheticcolumn is named that way on purpose. It is not safe to drop the suffix in any downstream citation.
Files
dbt_project.yml, profiles.yml project + connection config
seeds/raw_fda_signals.csv REAL — 34 firms from the n8n execution record
seeds/synthetic_*.csv SYNTHETIC — see above
scripts/extract_real_signals.py regenerates the real seed from n8n-gtm-orchestration
scripts/generate_synthetic_seeds.py regenerates the synthetic seeds (fixed seed)
models/staging/ cast/rename/clean, one model per source
models/marts/ dim_account, fact_signal (incremental), fact_touch,
fact_opportunity, funnel_summary
macros/test_value_in_range.sql custom generic test
tests/assert_briefed_firms_cleared_threshold.sql singular cross-artifact test
output/dbt-build-log.txt committed real `dbt build` output
Built something similar for your stack?
I build signal-detection, enrichment and outbound systems inside your own tools. You own everything — nothing is locked to me.