@altimateai/altimate-code 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.8.0] - 2026-06-01
9
+
10
+ Headlined by **dbt PR Review** — a Cloudflare-style, dbt/SQL-specialized code reviewer that emits a single **signed** verdict (`APPROVE` / `COMMENT` / `REQUEST_CHANGES`) where every *blocking* finding is backed by a deterministic `altimate-core` engine call over parsed SQL ASTs, not a model's opinion. An optional LLM lane adds advisory context but can never block. This release also adds a **native Trino driver**, the opt-in **completion-gate validators**, and reliability/cost fixes. A five-persona pre-release review drove a security hardening pass on the new `reviewer` agent (see Security).
11
+
12
+ ### Added
13
+
14
+ - **dbt PR Review — signed, deterministic verdicts on dbt pull requests.** New `altimate review` CLI command and a composite GitHub Action (`github/review`). The deterministic engine (column-lineage / DAG blast radius, query equivalence on before/after model SQL, PII classification, A–F grade + anti-pattern lint) is the **only** layer that can block; an optional LLM reviewer is clamped to ≤ warning and excluded from the gate, so a `REQUEST_CHANGES` is always provable and replayable. Runs in CI with **zero warehouse access** (consumes `dbt compile` artifacts), and the verdict is HMAC-signable and tamper-evident. `comment` mode never blocks; `gate` mode fails the check on `REQUEST_CHANGES`. The advisory model/credentials are configured on the Action (hosted `altimate_api_key`, or bring-your-own `model` + `model_api_key`); omit them to run deterministic-only. See [dbt PR Review docs](https://docs.altimate.sh/usage/dbt-pr-review/) and the copy-paste workflow in `github/review/examples/`. Depends on `@altimateai/altimate-core` ≥ 0.4.0. (#856)
15
+ - **Native Trino driver.** First-class Trino support over HTTP(S) with catalog/schema introspection and None / Basic / Bearer-token auth. **Migration note:** the dbt `trino` adapter previously mapped to the PostgreSQL driver; it now uses the native driver. Existing profiles are auto-aliased (`database` → `catalog`, `token` → `access_token`) and otherwise compatible. `trino-client` is an optional dependency — install it (`npm install trino-client`) to use Trino. (#795)
16
+ - **Completion-gate validator framework.** A new opt-in harness-side check
17
+ that runs after the LLM declares `finish === "stop"`. Two built-in
18
+ validators for dbt projects: `dbt-tests-pass` (runs `altimate-dbt test`
19
+ against modified models) and `dbt-schema-verify` (runs `altimate-dbt
20
+ schema-verify` against modified models). On failure, the framework
21
+ injects a synthetic user turn so the agent gets one more chance to fix
22
+ the issue, bounded by a per-session retry budget. Two opt-in modes:
23
+ `ALTIMATE_VALIDATORS_ENABLED=1` (enforcement + retries) and
24
+ `ALTIMATE_VALIDATORS_SHADOW=1` (telemetry-only — measure "would have
25
+ caught" rates without blocking). Default is **off** with zero overhead.
26
+ Two new telemetry events (`validator_check`, `validator_retries_exhausted`).
27
+ Configuration via `ALTIMATE_VALIDATORS_{MAX_RETRIES,TIMEOUT_MS,CONCURRENCY,DEBUG}`.
28
+ See [Validators docs](https://docs.altimate.sh/data-engineering/validators/)
29
+ for the full reference, performance characteristics, and the phased
30
+ rollout plan. (#849)
31
+
32
+ ### Fixed
33
+
34
+ - **`cancel()` race / idle-on-clean-exit.** A cancel arriving during normal loop teardown could leave a session without a `session.status:idle` event, leaving callers waiting on idle stuck. Idle is now emitted correctly on both the abort and clean-exit paths. (#845)
35
+ - **Prompt caching on the `altimate-backend` provider.** Enabled the `cache_control` trigger for the hosted provider so the litellm Anthropic fallback caches repeated prompt prefixes — cheaper and faster repeat turns (no-op on providers that ignore the marker). (#850)
36
+
37
+ ### Security
38
+
39
+ - **`reviewer` agent hardened to deny bash.** The v0.8.0 pre-release review found that the new `reviewer` agent — advertised as "read-only" — had a bash allowlist (`git log *`, `cat *`, `ls *`) that was bypassable: shell redirects rode *inside* a matched command (`git log -p > ~/.ssh/authorized_keys` was allowed) and `cat *` could read arbitrary files (e.g. `~/.altimate/altimate.json`) and exfiltrate them through the PR comment the agent posts. Bash is now **denied** for the reviewer (it uses the structured `read`/`grep`/`glob` tools + the verdict engine, which does its own diffing); the agent description was corrected; and the reviewer prompt now treats PR content as untrusted input. The CI Action path was never affected — its LLM lane runs with no tools. (#856)
40
+
8
41
  ## [0.7.3] - 2026-05-24
9
42
 
10
43
  A telemetry-driven hardening release. Five P0 fixes merged from a `telemetry-analysis-2026-05-21` pass — every one tied to a measured failure number from the App Insights pipeline. The headline wins are user-visible: `finops_*` tools now work without an explicit `warehouse=` parameter (auto-pick the first compatible connection); `project_scan` no longer crashes on hosts where `git` isn't in PATH (the silent 437-user regression was masked by the PII filter collapsing the binary name to `?` in error messages); and `webfetch` caches 404/410/451 responses for up to 30 minutes so the agent stops re-asking dead URLs. Two telemetry-only fixes (build-agent name normalization, Anthropic token-count semantics) clean up dashboard mis-bucketing without changing user-visible behavior.
@@ -23515,6 +23515,117 @@ var init_compile = __esm(() => {
23515
23515
  init_dbt_cli();
23516
23516
  });
23517
23517
 
23518
+ // src/commands/schema-verify.ts
23519
+ var exports_schema_verify = {};
23520
+ __export(exports_schema_verify, {
23521
+ schemaVerify: () => schemaVerify
23522
+ });
23523
+ async function schemaVerify(adapter, args) {
23524
+ const model = flag2(args, "model");
23525
+ if (!model)
23526
+ return { error: "Missing --model" };
23527
+ const parsed = await adapter.parseManifest();
23528
+ const node = parsed?.nodeMetaMap.lookupByBaseName(model);
23529
+ if (!node) {
23530
+ return {
23531
+ error: `Model '${model}' not found in manifest. Did you run \`altimate-dbt compile\` or \`altimate-dbt build\` first?`
23532
+ };
23533
+ }
23534
+ const expectedEntries = Object.values(node.columns ?? {});
23535
+ let actual;
23536
+ try {
23537
+ actual = await adapter.getColumnsOfModel(model);
23538
+ } catch (e2) {
23539
+ return {
23540
+ error: `Failed to read actual columns for '${model}': ${e2 instanceof Error ? e2.message : String(e2)}. Build the model first: altimate-dbt build --model ${model}`
23541
+ };
23542
+ }
23543
+ if (!actual) {
23544
+ return {
23545
+ error: `Model '${model}' is in the manifest but has no warehouse table. Build it first: altimate-dbt build --model ${model}`
23546
+ };
23547
+ }
23548
+ if (expectedEntries.length === 0) {
23549
+ return {
23550
+ model,
23551
+ verdict: "no-spec",
23552
+ message: `Model '${model}' has no columns declared in schema.yml. There is no spec to verify against; the agent's column choices are unconstrained.`,
23553
+ actual_columns: actual.map((c) => c.column)
23554
+ };
23555
+ }
23556
+ const actualNames = actual.map((c) => c.column ?? "");
23557
+ const actualLower = actualNames.map((n) => n.toLowerCase());
23558
+ const expectedNames = expectedEntries.map((c) => c.name ?? "");
23559
+ const expectedLower = expectedNames.map((n) => n.toLowerCase());
23560
+ const actualSet = new Set(actualLower);
23561
+ const expectedSet = new Set(expectedLower);
23562
+ const columns_extra = [];
23563
+ for (let i2 = 0;i2 < actualNames.length; i2++) {
23564
+ const low = actualLower[i2] ?? "";
23565
+ const orig = actualNames[i2] ?? "";
23566
+ if (!expectedSet.has(low))
23567
+ columns_extra.push(orig);
23568
+ }
23569
+ const columns_missing = [];
23570
+ for (let i2 = 0;i2 < expectedNames.length; i2++) {
23571
+ const low = expectedLower[i2] ?? "";
23572
+ const orig = expectedNames[i2] ?? "";
23573
+ if (!actualSet.has(low))
23574
+ columns_missing.push(orig);
23575
+ }
23576
+ const intersection = expectedLower.filter((n) => actualSet.has(n));
23577
+ const actualIntersection = actualLower.filter((n) => expectedSet.has(n));
23578
+ const columns_reordered = [];
23579
+ for (let i2 = 0;i2 < intersection.length; i2++) {
23580
+ const expectedAtI = intersection[i2] ?? "";
23581
+ const actualAtI = actualIntersection[i2] ?? "";
23582
+ if (expectedAtI !== actualAtI) {
23583
+ const colLower = expectedAtI;
23584
+ const actualIdx = actualLower.indexOf(colLower);
23585
+ const expectedPos = expectedLower.indexOf(colLower);
23586
+ const original = expectedNames[expectedPos] ?? colLower;
23587
+ columns_reordered.push({
23588
+ column: original,
23589
+ actual_position: actualIdx,
23590
+ expected_position: expectedPos
23591
+ });
23592
+ }
23593
+ }
23594
+ const actualTypeByName = {};
23595
+ for (const c of actual)
23596
+ actualTypeByName[c.column.toLowerCase()] = c.dtype || "";
23597
+ const type_mismatches = [];
23598
+ for (const ec of expectedEntries) {
23599
+ const key = ec.name.toLowerCase();
23600
+ if (!actualTypeByName[key])
23601
+ continue;
23602
+ if (!ec.data_type)
23603
+ continue;
23604
+ if (actualTypeByName[key].toLowerCase() !== ec.data_type.toLowerCase()) {
23605
+ type_mismatches.push({
23606
+ column: ec.name,
23607
+ actual_type: actualTypeByName[key],
23608
+ expected_type: ec.data_type
23609
+ });
23610
+ }
23611
+ }
23612
+ const verdict = columns_extra.length === 0 && columns_missing.length === 0 && columns_reordered.length === 0 && type_mismatches.length === 0 ? "match" : "mismatch";
23613
+ return {
23614
+ model,
23615
+ verdict,
23616
+ expected_columns: expectedNames,
23617
+ actual_columns: actualNames,
23618
+ columns_extra,
23619
+ columns_missing,
23620
+ columns_reordered,
23621
+ type_mismatches
23622
+ };
23623
+ }
23624
+ function flag2(args, name) {
23625
+ const i2 = args.indexOf(`--${name}`);
23626
+ return i2 >= 0 ? args[i2 + 1] : undefined;
23627
+ }
23628
+
23518
23629
  // src/commands/build.ts
23519
23630
  var exports_build = {};
23520
23631
  __export(exports_build, {
@@ -23524,7 +23635,7 @@ __export(exports_build, {
23524
23635
  build: () => build
23525
23636
  });
23526
23637
  async function build(adapter, args) {
23527
- const model = flag2(args, "model");
23638
+ const model = flag3(args, "model");
23528
23639
  const downstream = args.includes("--downstream");
23529
23640
  if (!model) {
23530
23641
  if (downstream)
@@ -23536,10 +23647,14 @@ async function build(adapter, args) {
23536
23647
  modelName: model,
23537
23648
  plusOperatorRight: downstream ? "+" : ""
23538
23649
  });
23539
- return format(result);
23650
+ const formatted = format(result);
23651
+ if (!("error" in formatted)) {
23652
+ return { ...formatted, schema_verify: await safeVerify(adapter, model) };
23653
+ }
23654
+ return formatted;
23540
23655
  }
23541
23656
  async function run3(adapter, args) {
23542
- const model = flag2(args, "model");
23657
+ const model = flag3(args, "model");
23543
23658
  if (!model)
23544
23659
  return { error: "Missing --model" };
23545
23660
  const downstream = args.includes("--downstream");
@@ -23551,7 +23666,7 @@ async function run3(adapter, args) {
23551
23666
  return format(result);
23552
23667
  }
23553
23668
  async function test(adapter, args) {
23554
- const model = flag2(args, "model");
23669
+ const model = flag3(args, "model");
23555
23670
  if (!model)
23556
23671
  return { error: "Missing --model" };
23557
23672
  const result = await adapter.unsafeRunModelTestImmediately(model);
@@ -23559,17 +23674,82 @@ async function test(adapter, args) {
23559
23674
  }
23560
23675
  async function project2(adapter) {
23561
23676
  const result = await adapter.unsafeBuildProjectImmediately();
23562
- return format(result);
23677
+ const formatted = format(result);
23678
+ if ("error" in formatted)
23679
+ return formatted;
23680
+ try {
23681
+ const parsed = await adapter.parseManifest();
23682
+ const nodes = parsed?.nodeMetaMap?.nodes ? Array.from(parsed.nodeMetaMap.nodes()) : [];
23683
+ const verified = [];
23684
+ const errored = [];
23685
+ let nospec_count = 0;
23686
+ for (const node of nodes) {
23687
+ const resType = node.resource_type;
23688
+ if (resType !== "model")
23689
+ continue;
23690
+ const name = node.name;
23691
+ if (!name)
23692
+ continue;
23693
+ const cols = node.columns ?? {};
23694
+ if (Object.keys(cols).length === 0) {
23695
+ nospec_count++;
23696
+ continue;
23697
+ }
23698
+ try {
23699
+ const v2 = await schemaVerify(adapter, ["--model", name]);
23700
+ if ("error" in v2) {
23701
+ errored.push({ model: name, error: String(v2.error) });
23702
+ } else if (v2.verdict === "no-spec") {
23703
+ nospec_count++;
23704
+ } else {
23705
+ verified.push(v2);
23706
+ }
23707
+ } catch (e2) {
23708
+ errored.push({ model: name, error: e2 instanceof Error ? e2.message : String(e2) });
23709
+ }
23710
+ }
23711
+ const mismatches = verified.filter((r2) => r2.verdict === "mismatch");
23712
+ const matches = verified.filter((r2) => r2.verdict === "match");
23713
+ return {
23714
+ ...formatted,
23715
+ schema_verify_summary: {
23716
+ models_checked: verified.length + errored.length,
23717
+ match: matches.length,
23718
+ mismatch: mismatches.length,
23719
+ no_spec: nospec_count,
23720
+ errored: errored.length,
23721
+ mismatches,
23722
+ ...errored.length > 0 && { errors: errored }
23723
+ }
23724
+ };
23725
+ } catch (e2) {
23726
+ return {
23727
+ ...formatted,
23728
+ schema_verify_summary: {
23729
+ error: `Bulk schema-verify failed: ${e2 instanceof Error ? e2.message : String(e2)}. Run \`altimate-dbt schema-verify --model <name>\` per model to inspect.`
23730
+ }
23731
+ };
23732
+ }
23733
+ }
23734
+ async function safeVerify(adapter, model) {
23735
+ try {
23736
+ return await schemaVerify(adapter, ["--model", model]);
23737
+ } catch (e2) {
23738
+ return {
23739
+ error: `schema-verify failed: ${e2 instanceof Error ? e2.message : String(e2)}. Run \`altimate-dbt schema-verify --model ${model}\` manually to inspect.`
23740
+ };
23741
+ }
23563
23742
  }
23564
23743
  function format(result) {
23565
23744
  if (result?.stderr)
23566
23745
  return { error: result.stderr, stdout: result.stdout };
23567
23746
  return { stdout: result?.stdout ?? "" };
23568
23747
  }
23569
- function flag2(args, name) {
23748
+ function flag3(args, name) {
23570
23749
  const i2 = args.indexOf(`--${name}`);
23571
23750
  return i2 >= 0 ? args[i2 + 1] : undefined;
23572
23751
  }
23752
+ var init_build = () => {};
23573
23753
 
23574
23754
  // src/commands/execute.ts
23575
23755
  var exports_execute = {};
@@ -23577,11 +23757,11 @@ __export(exports_execute, {
23577
23757
  execute: () => execute
23578
23758
  });
23579
23759
  async function execute(adapter, args) {
23580
- const sql = flag3(args, "query");
23760
+ const sql = flag4(args, "query");
23581
23761
  if (!sql)
23582
23762
  return { error: "Missing --query" };
23583
- const model = flag3(args, "model") ?? "";
23584
- const raw = flag3(args, "limit");
23763
+ const model = flag4(args, "model") ?? "";
23764
+ const raw = flag4(args, "limit");
23585
23765
  const limit = raw !== undefined ? parseInt(raw, 10) : undefined;
23586
23766
  try {
23587
23767
  if (limit !== undefined && !Number.isNaN(limit))
@@ -23594,7 +23774,7 @@ async function execute(adapter, args) {
23594
23774
  throw e2;
23595
23775
  }
23596
23776
  }
23597
- function flag3(args, name) {
23777
+ function flag4(args, name) {
23598
23778
  const i2 = args.indexOf(`--${name}`);
23599
23779
  return i2 >= 0 ? args[i2 + 1] : undefined;
23600
23780
  }
@@ -23610,7 +23790,7 @@ __export(exports_columns, {
23610
23790
  columns: () => columns
23611
23791
  });
23612
23792
  async function columns(adapter, args) {
23613
- const model = flag4(args, "model");
23793
+ const model = flag5(args, "model");
23614
23794
  if (!model)
23615
23795
  return { error: "Missing --model" };
23616
23796
  try {
@@ -23624,8 +23804,8 @@ async function columns(adapter, args) {
23624
23804
  }
23625
23805
  }
23626
23806
  async function source(adapter, args) {
23627
- const name = flag4(args, "source");
23628
- const table = flag4(args, "table");
23807
+ const name = flag5(args, "source");
23808
+ const table = flag5(args, "table");
23629
23809
  if (!name)
23630
23810
  return { error: "Missing --source" };
23631
23811
  if (!table)
@@ -23641,15 +23821,15 @@ async function source(adapter, args) {
23641
23821
  }
23642
23822
  }
23643
23823
  async function values(adapter, args) {
23644
- const model = flag4(args, "model");
23645
- const col = flag4(args, "column");
23824
+ const model = flag5(args, "model");
23825
+ const col = flag5(args, "column");
23646
23826
  if (!model)
23647
23827
  return { error: "Missing --model" };
23648
23828
  if (!col)
23649
23829
  return { error: "Missing --column" };
23650
23830
  return adapter.getColumnValues(model, col);
23651
23831
  }
23652
- function flag4(args, name) {
23832
+ function flag5(args, name) {
23653
23833
  const i2 = args.indexOf(`--${name}`);
23654
23834
  return i2 >= 0 ? args[i2 + 1] : undefined;
23655
23835
  }
@@ -23661,7 +23841,7 @@ __export(exports_graph, {
23661
23841
  children: () => children
23662
23842
  });
23663
23843
  async function children(adapter, args) {
23664
- const model = flag5(args, "model");
23844
+ const model = flag6(args, "model");
23665
23845
  if (!model)
23666
23846
  return { error: "Missing --model" };
23667
23847
  try {
@@ -23674,7 +23854,7 @@ async function children(adapter, args) {
23674
23854
  }
23675
23855
  }
23676
23856
  async function parents(adapter, args) {
23677
- const model = flag5(args, "model");
23857
+ const model = flag6(args, "model");
23678
23858
  if (!model)
23679
23859
  return { error: "Missing --model" };
23680
23860
  try {
@@ -23686,7 +23866,7 @@ async function parents(adapter, args) {
23686
23866
  throw e2;
23687
23867
  }
23688
23868
  }
23689
- function flag5(args, name) {
23869
+ function flag6(args, name) {
23690
23870
  const i2 = args.indexOf(`--${name}`);
23691
23871
  return i2 >= 0 ? args[i2 + 1] : undefined;
23692
23872
  }
@@ -23705,7 +23885,7 @@ async function deps(adapter) {
23705
23885
  return format2(result);
23706
23886
  }
23707
23887
  async function add(adapter, args) {
23708
- const raw = flag6(args, "packages");
23888
+ const raw = flag7(args, "packages");
23709
23889
  if (!raw)
23710
23890
  return { error: "Missing --packages" };
23711
23891
  const result = await adapter.installDbtPackages(raw.split(","));
@@ -23716,7 +23896,7 @@ function format2(result) {
23716
23896
  return { error: result.stderr, stdout: result.stdout };
23717
23897
  return { stdout: result?.stdout ?? "" };
23718
23898
  }
23719
- function flag6(args, name) {
23899
+ function flag7(args, name) {
23720
23900
  const i2 = args.indexOf(`--${name}`);
23721
23901
  return i2 >= 0 ? args[i2 + 1] : undefined;
23722
23902
  }
@@ -23854,6 +24034,7 @@ var USAGE = {
23854
24034
  execute: "Execute SQL --query <sql> [--model <name>] [--limit <n>]",
23855
24035
  columns: "Get columns of model --model <name>",
23856
24036
  "columns-source": "Get columns of source --source <name> --table <name>",
24037
+ "schema-verify": "Diff a model's actual columns against the schema.yml spec --model <name>. Returns columns_extra / columns_missing / columns_reordered / type_mismatches. verdict: match | mismatch | no-spec",
23857
24038
  "column-values": "Get column values --model <name> --column <col>",
23858
24039
  children: "Get downstream models --model <name>",
23859
24040
  parents: "Get upstream models --model <name>",
@@ -23863,7 +24044,7 @@ var USAGE = {
23863
24044
  };
23864
24045
  var cmd = process.argv[2];
23865
24046
  var rest = process.argv.slice(3);
23866
- function flag7(args, name) {
24047
+ function flag8(args, name) {
23867
24048
  const i2 = args.indexOf(`--${name}`);
23868
24049
  return i2 >= 0 ? args[i2 + 1] : undefined;
23869
24050
  }
@@ -23946,7 +24127,7 @@ async function main() {
23946
24127
  const cfg = await read();
23947
24128
  if (!cfg)
23948
24129
  return { error: "No config found. Run: altimate-dbt init" };
23949
- const dirFlag = flag7(rest, "project-dir");
24130
+ const dirFlag = flag8(rest, "project-dir");
23950
24131
  if (dirFlag) {
23951
24132
  cfg.projectRoot = resolve4(dirFlag);
23952
24133
  } else {
@@ -23982,13 +24163,13 @@ async function main() {
23982
24163
  result = await (await Promise.resolve().then(() => (init_compile(), exports_compile))).query(adapter, rest);
23983
24164
  break;
23984
24165
  case "build":
23985
- result = await (await Promise.resolve().then(() => exports_build)).build(adapter, rest);
24166
+ result = await (await Promise.resolve().then(() => (init_build(), exports_build))).build(adapter, rest);
23986
24167
  break;
23987
24168
  case "run":
23988
- result = await (await Promise.resolve().then(() => exports_build)).run(adapter, rest);
24169
+ result = await (await Promise.resolve().then(() => (init_build(), exports_build))).run(adapter, rest);
23989
24170
  break;
23990
24171
  case "test":
23991
- result = await (await Promise.resolve().then(() => exports_build)).test(adapter, rest);
24172
+ result = await (await Promise.resolve().then(() => (init_build(), exports_build))).test(adapter, rest);
23992
24173
  break;
23993
24174
  case "execute":
23994
24175
  result = await (await Promise.resolve().then(() => (init_execute(), exports_execute))).execute(adapter, rest);
@@ -24002,6 +24183,9 @@ async function main() {
24002
24183
  case "column-values":
24003
24184
  result = await (await Promise.resolve().then(() => exports_columns)).values(adapter, rest);
24004
24185
  break;
24186
+ case "schema-verify":
24187
+ result = await (await Promise.resolve().then(() => exports_schema_verify)).schemaVerify(adapter, rest);
24188
+ break;
24005
24189
  case "children":
24006
24190
  result = await (await Promise.resolve().then(() => (init_graph(), exports_graph))).children(adapter, rest);
24007
24191
  break;
package/package.json CHANGED
@@ -7,20 +7,20 @@
7
7
  "scripts": {
8
8
  "postinstall": "bun ./postinstall.mjs || node ./postinstall.mjs"
9
9
  },
10
- "version": "0.7.3",
10
+ "version": "0.8.0",
11
11
  "license": "MIT",
12
12
  "dependencies": {
13
- "@altimateai/altimate-core": "0.3.1"
13
+ "@altimateai/altimate-core": "0.4.0"
14
14
  },
15
15
  "optionalDependencies": {
16
- "@altimateai/altimate-code-windows-x64": "0.7.3",
17
- "@altimateai/altimate-code-linux-x64": "0.7.3",
18
- "@altimateai/altimate-code-linux-arm64": "0.7.3",
19
- "@altimateai/altimate-code-windows-x64-baseline": "0.7.3",
20
- "@altimateai/altimate-code-darwin-arm64": "0.7.3",
21
- "@altimateai/altimate-code-linux-x64-baseline": "0.7.3",
22
- "@altimateai/altimate-code-darwin-x64": "0.7.3",
23
- "@altimateai/altimate-code-darwin-x64-baseline": "0.7.3"
16
+ "@altimateai/altimate-code-linux-arm64": "0.8.0",
17
+ "@altimateai/altimate-code-darwin-arm64": "0.8.0",
18
+ "@altimateai/altimate-code-linux-x64-baseline": "0.8.0",
19
+ "@altimateai/altimate-code-darwin-x64-baseline": "0.8.0",
20
+ "@altimateai/altimate-code-windows-x64-baseline": "0.8.0",
21
+ "@altimateai/altimate-code-darwin-x64": "0.8.0",
22
+ "@altimateai/altimate-code-windows-x64": "0.8.0",
23
+ "@altimateai/altimate-code-linux-x64": "0.8.0"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "pg": ">=8",
@@ -31,7 +31,8 @@
31
31
  "mssql": ">=11",
32
32
  "oracledb": ">=6",
33
33
  "duckdb": ">=1",
34
- "@clickhouse/client": ">=1"
34
+ "@clickhouse/client": ">=1",
35
+ "trino-client": ">=0.2"
35
36
  },
36
37
  "peerDependenciesMeta": {
37
38
  "pg": {
@@ -60,6 +61,9 @@
60
61
  },
61
62
  "@clickhouse/client": {
62
63
  "optional": true
64
+ },
65
+ "trino-client": {
66
+ "optional": true
63
67
  }
64
68
  }
65
69
  }
@@ -1,6 +1,30 @@
1
1
  ---
2
2
  name: dbt-develop
3
- description: Create and modify dbt models — staging, intermediate, marts, incremental, medallion architecture. Use when building new SQL models, extending existing ones, scaffolding YAML configs, or reorganizing project structure. Powered by altimate-dbt.
3
+ applyPaths:
4
+ - "dbt_project.yml"
5
+ - "**/dbt_project.yml"
6
+ description: |
7
+ REQUIRED before writing or modifying ANY dbt model. Invoke this skill FIRST
8
+ whenever a task says "create", "build", "add", "modify", "update", "fix", or
9
+ "refactor" a dbt model, staging file, mart, incremental, or snapshot.
10
+
11
+ Skipping this skill is the leading cause of silent-correctness bugs —
12
+ models that compile and `dbt build` cleanly but produce wrong values. It
13
+ contains the patterns that prevent the most common such bugs encountered
14
+ in real dbt projects:
15
+
16
+ • Incremental high-water marks (`>=` vs `>` ties → silent row dropout)
17
+ • Snapshot strategy selection (timestamp vs check, `unique_key` choice)
18
+ • `LEFT JOIN + COUNT(*)` phantom rows from unmatched parents
19
+ • Type harmonization in `COALESCE` / `CASE` / `UNION` legs
20
+ • Date-spine completeness (every period present, even empty ones)
21
+ • Off-by-one window boundaries (`BETWEEN d - (N-1) AND d` for N-wide)
22
+ • Uniqueness enforcement when schema implies a key
23
+ • Window-function `LIMIT` with deterministic tiebreaker
24
+ • Verifying transformation correctness with dbt unit tests, not just `dbt build`
25
+ • Enumerating every requested deliverable and checking each exists on disk
26
+
27
+ Do not start writing SQL until this skill is loaded. Powered by altimate-dbt.
4
28
  ---
5
29
 
6
30
  # dbt Model Development
@@ -31,6 +55,12 @@ description: Create and modify dbt models — staging, intermediate, marts, incr
31
55
 
32
56
  Before writing any SQL:
33
57
  - Read the task requirements carefully
58
+ - **Enumerate every concrete deliverable the task asks for** — write down each
59
+ model name, every column/test/config change mentioned, and any "create N
60
+ models" count. This list becomes the checklist you verify against in
61
+ step 4. A task asking for four models is not done if only three exist on
62
+ disk. If the task references a `schema.yml`, `_models.yml`, or similar
63
+ spec file, every entry there is a deliverable.
34
64
  - Identify which layer this model belongs to (staging, intermediate, mart)
35
65
  - Check existing models for naming conventions and patterns
36
66
  - **Check dependencies:** If `packages.yml` exists, check for `dbt_packages/` or `package-lock.yml`. Only run `dbt deps` if packages are declared but not yet installed.
@@ -98,6 +128,51 @@ altimate-dbt compile --model <name> # catch Jinja errors
98
128
  altimate-dbt build --model <name> # materialize + run tests
99
129
  ```
100
130
 
131
+ **Verify transformation correctness with unit tests:**
132
+
133
+ For models with non-trivial transformation logic — aggregations, JOINs, CASE/WHEN,
134
+ window functions, ratio / rate / NPS calculations, COALESCE / NULL coalescing, date
135
+ spines, incremental merge keys — generate and run dbt unit tests before declaring
136
+ the model done. Schema checks ("table exists with the right columns") only verify
137
+ mechanics; value-level correctness needs unit tests.
138
+
139
+ Invoke the **dbt-unit-tests** skill, which will:
140
+ - Analyze your SQL for the constructs above
141
+ - Build typed mock input rows from the manifest
142
+ - Compute expected outputs by running the SQL against the mocks
143
+ - Write a `unit_tests:` block in the model's `_models.yml`
144
+
145
+ Then run them:
146
+ ```bash
147
+ altimate-dbt test --model <name> # runs unit tests + schema tests
148
+ ```
149
+
150
+ If a unit test fails, the transformation logic is wrong — **fix the SQL, do not
151
+ weaken the test**. Skip unit tests only for genuinely trivial models: pure renames,
152
+ simple `SELECT *` passthrough, materialization / config-only changes, format-only
153
+ edits.
154
+
155
+ **Verify every requested deliverable exists:**
156
+
157
+ Walk the checklist you wrote in the Plan step. For each model the task asked
158
+ for, confirm: (1) the `.sql` file exists in the project, (2) it appears in
159
+ `altimate-dbt info` / the manifest, (3) `altimate-dbt columns --model <name>`
160
+ returns the expected columns, (4) the materialization config matches the
161
+ spec. A task that asked for N models is not complete with N-1 files on disk,
162
+ even if those N-1 build cleanly. Use:
163
+
164
+ ```bash
165
+ ls models/ # confirm every requested file exists
166
+ altimate-dbt info # confirm every requested model is in the project
167
+ ```
168
+
169
+ **Diff column shape against the spec — use the `dbt-schema-verify` skill.**
170
+ For each model the task touched, run `altimate-dbt schema-verify --model
171
+ <name>` and treat any `mismatch` verdict as "not done." Full procedure,
172
+ output interpretation, and fallback (when `altimate-dbt` is missing) live
173
+ in the dedicated **dbt-schema-verify** skill, which auto-loads alongside
174
+ this one.
175
+
101
176
  **Verify the output:**
102
177
  ```bash
103
178
  altimate-dbt columns --model <name> # confirm expected columns exist
@@ -127,6 +202,203 @@ Use `altimate-dbt children` and `altimate-dbt parents` to verify the DAG is inta
127
202
  3. **Match existing patterns.** Read 2-3 existing models in the same directory before writing.
128
203
  4. **One model, one purpose.** A staging model should not contain business logic. An intermediate model should not be materialized as a table unless it has consumers.
129
204
  5. **Fix ALL errors, not just yours.** After creating/modifying models, run a full `dbt build`. If ANY model fails — even pre-existing ones you didn't touch — fix them. Your job is to leave the project in a fully working state.
205
+ 6. **Verify transformation correctness, not just mechanics.** For non-trivial models, generate and run dbt unit tests as part of the validate step (use the `dbt-unit-tests` skill). Passing `dbt build` only proves the SQL is syntactically valid — it doesn't prove the *values* are right.
206
+ 7. **Enumerate deliverables, then check them off.** The task is not done until every model, column, test, and config change explicitly requested exists on disk and in the manifest. Re-read the prompt at the end and verify each requested item — don't trust your own intermediate "done" feeling.
207
+ 8. **Match the column spec exactly — and verify it mechanically, not by inspection.** Use the dedicated **dbt-schema-verify** skill. Before declaring any model task done, run `altimate-dbt schema-verify --model <name>` and treat any `mismatch` verdict as "not done." Adding "helpful" extras (rank breakdowns, name-resolved fields, lineage metadata), reordering columns "more logically", or substituting synonyms (`supplier_id` for `supplier_company`, `transaction_type_name` for `transaction_type`) all break equality tests. The contract is what the spec says, not what you think would be useful.
208
+
209
+ ## Common Pitfalls in Transformation Logic
210
+
211
+ When the model involves any of the following SQL constructs, watch for these
212
+ generic bugs that mostly compile cleanly but produce wrong values:
213
+
214
+ ### Incremental models and snapshots
215
+
216
+ - **High-water mark boundary**: in the `{% if is_incremental() %}` filter, use
217
+ `>=` (not `>`) when the upstream timestamp can repeat or land exactly on the
218
+ prior max — a strict `>` silently drops every event that ties with the most
219
+ recent prior load.
220
+ - **`unique_key` choice**: must be the *natural* unique key of the row. Picking
221
+ a column that is not actually unique (e.g. a foreign-key like `customer_id`
222
+ instead of `order_id`) causes silent merges and lost rows.
223
+ - **`on_schema_change`**: set `append_new_columns` (or `sync_all_columns` if
224
+ upstream evolves) so a new source column doesn't NULL-out existing data.
225
+ - **Snapshots — strategy selection**: use `strategy='timestamp'` only when the
226
+ source has a reliable `updated_at` that monotonically increases on every
227
+ change. If `updated_at` can be NULL, be reset, or move backwards, switch to
228
+ `strategy='check'` with an explicit `check_cols` list. Verify by querying
229
+ the source for `MAX(updated_at)` and looking for repeats or NULLs.
230
+ - **Backfilling**: `--full-refresh` rebuilds incremental tables from scratch.
231
+ Use it whenever you change the incremental SQL, the merge key, or
232
+ `on_schema_change`.
233
+
234
+ ### Date and time arithmetic
235
+
236
+ - **"current age", "days since", "elapsed", "tenure"** — if the column is not
237
+ pre-computed in the source, compute it. For year-based age, account for
238
+ month/day so the change happens on the birthday, not on Jan 1:
239
+ ```sql
240
+ date_part('year', age(birth_date)) -- in postgres-family
241
+ EXTRACT(YEAR FROM CURRENT_DATE) - EXTRACT(YEAR FROM birth_date)
242
+ - CASE WHEN (EXTRACT(MONTH FROM CURRENT_DATE), EXTRACT(DAY FROM CURRENT_DATE))
243
+ < (EXTRACT(MONTH FROM birth_date), EXTRACT(DAY FROM birth_date))
244
+ THEN 1 ELSE 0 END -- portable form
245
+ ```
246
+ - **Date spines**: when a daily/weekly/monthly model must have a row for
247
+ every period (even periods with zero events), build a spine first with
248
+ `dbt_utils.date_spine` or a recursive CTE, then LEFT JOIN the events onto
249
+ it. Never compute date series by `DISTINCT date_col FROM events` — that
250
+ silently drops empty periods.
251
+ - **Date boundaries for windowed sums**: rolling-N-day windows expressed as
252
+ `BETWEEN d - (N-1) AND d` (inclusive both ends) give a width of exactly N.
253
+ `BETWEEN d - N AND d` gives N+1 — a classic off-by-one.
254
+
255
+ ### Type harmonization in `COALESCE` / `CASE` / `UNION`
256
+
257
+ `COALESCE(timestamp_col, integer_col)` and `CASE WHEN ... THEN '0' ELSE 0 END`
258
+ fail at compile or coerce silently to whatever type the engine guesses.
259
+ Cast every branch / argument to the same explicit type:
260
+ ```sql
261
+ COALESCE(CAST(timestamp_col AS TIMESTAMP), CAST(integer_col AS TIMESTAMP))
262
+ CASE WHEN cond THEN CAST('0' AS NUMERIC) ELSE CAST(0 AS NUMERIC) END
263
+ ```
264
+ Same applies to `UNION` / `UNION ALL` — column types must match across legs.
265
+
266
+ ### String concatenation with `NULL` operands
267
+
268
+ `||` and `CONCAT()` propagate `NULL` in most engines — a single `NULL` operand
269
+ makes the whole expression `NULL`. When the result feeds an equality join or
270
+ surrogate-key generation, that's an invisible row-dropper:
271
+ ```sql
272
+ -- Wrong: NULL region OR NULL segment produces NULL geo_segment
273
+ region || '-' || segment AS geo_segment
274
+
275
+ -- Right: explicit placeholder
276
+ COALESCE(region, 'UNKNOWN') || '-' || COALESCE(segment, 'UNKNOWN') AS geo_segment
277
+ ```
278
+ Use `CONCAT_WS()` if your dialect supports it (Snowflake, BigQuery) — it
279
+ skips `NULL` operands instead of propagating them, which is usually safer
280
+ than a static placeholder.
281
+
282
+ ### dbt model versioning (dbt 1.8+)
283
+
284
+ When the task asks for a v2 of an existing model (and v1 must keep
285
+ working — common during a rolling schema change), use dbt's **versioned
286
+ models** feature, not a sibling `.sql` file with a `_v2` suffix:
287
+
288
+ 1. Create the new SQL file (e.g. `dim_accounts_v2.sql`).
289
+ 2. Add a `versions:` block to the model's entry in `_models.yml`:
290
+ ```yaml
291
+ models:
292
+ - name: dim_accounts
293
+ latest_version: 1
294
+ versions:
295
+ - v: 1
296
+ - v: 2
297
+ defined_in: dim_accounts_v2 # filename without .sql
298
+ ```
299
+ 3. Downstream callers reference the version with
300
+ `{{ ref('dim_accounts', v=2) }}`. Without the `versions:` block, dbt
301
+ treats `dim_accounts_v2` as an unrelated sibling model — versioning
302
+ tests will fail and v1↔v2 lineage won't appear in the DAG.
303
+
304
+ ### Refactoring a CTE into its own model — preserve row-count semantics
305
+
306
+ When a task asks to extract a CTE from a larger model into its own
307
+ intermediate model, the new model's row count must match what the CTE
308
+ produced inside the original. Common bug: the CTE was on the parent side of
309
+ a `LEFT JOIN` that preserved parent rows with no children; the agent's
310
+ extracted model starts `FROM child_table` and joins back to the parent,
311
+ silently dropping parents that have no children.
312
+
313
+ **Rule of thumb:** the extracted model should start `FROM` the same table
314
+ the CTE started from. Build the extracted model inside-out from the
315
+ parent's perspective, not the child's.
316
+
317
+ ```sql
318
+ -- Original CTE (inside the larger model):
319
+ -- WITH agg_users AS (
320
+ -- SELECT p.project_id, listagg(u.user_id) AS users
321
+ -- FROM projects p
322
+ -- LEFT JOIN project_users u ON u.project_id = p.project_id
323
+ -- GROUP BY p.project_id
324
+ -- )
325
+ --
326
+ -- Right refactor — preserves projects with no users:
327
+ SELECT p.project_id, listagg(u.user_id) AS users
328
+ FROM {{ ref('projects') }} p
329
+ LEFT JOIN {{ ref('project_users') }} u ON u.project_id = p.project_id
330
+ GROUP BY p.project_id
331
+
332
+ -- Wrong refactor — drops projects with no users:
333
+ SELECT u.project_id, listagg(u.user_id) AS users
334
+ FROM {{ ref('project_users') }} u
335
+ GROUP BY u.project_id -- projects with zero users vanish
336
+ ```
337
+
338
+ **Verification** (in order of preference):
339
+
340
+ ```sql
341
+ -- If dbt_utils is installed, add to schema.yml on the extracted model:
342
+ tests:
343
+ - dbt_utils.equal_rowcount:
344
+ compare_model: ref('<parent_table>')
345
+
346
+ -- If dbt-audit-helper is installed:
347
+ {{ audit_helper.compare_relations(
348
+ a_relation=ref('<original_or_parent>'),
349
+ b_relation=ref('<extracted>'),
350
+ primary_key='<key>'
351
+ ) }}
352
+
353
+ -- Manual fallback — always available:
354
+ SELECT (SELECT COUNT(*) FROM {{ ref('<parent>') }}) AS parent_rows,
355
+ (SELECT COUNT(*) FROM {{ ref('<extracted>') }}) AS extracted_rows
356
+ -- These must match if the original CTE was LEFT-joined to its parent.
357
+ ```
358
+
359
+ If `extracted_rows < parent_rows`, the refactor is wrong — you've turned a
360
+ LEFT JOIN into an INNER JOIN somewhere. Same trap shows up when filtering a
361
+ right-side column in `WHERE` (silently converts the LEFT JOIN to an INNER
362
+ JOIN); move that filter into the `ON` clause.
363
+
364
+ ### Uniqueness when the schema implies it
365
+
366
+ If the model is named `dim_*`, has a `unique` test in `schema.yml`, or the
367
+ task says "one row per X", the model must enforce that grain. Source data
368
+ often has duplicates. Use one of:
369
+ - `SELECT DISTINCT ...`
370
+ - `QUALIFY ROW_NUMBER() OVER (PARTITION BY <key> ORDER BY <tiebreaker>) = 1`
371
+ - `GROUP BY <key>` with explicit aggregation of all other columns
372
+
373
+ ### Window functions / ranking with `LIMIT` and ties
374
+
375
+ `ORDER BY metric DESC LIMIT N` (and equivalently `ROW_NUMBER() / RANK() OVER
376
+ (PARTITION BY ... ORDER BY metric)` filtered to `<= N`) over a column with
377
+ ties returns a **non-deterministic** set — the engine can pick any N of the
378
+ tied rows, and the choice often differs across runs, engines, or warehouse
379
+ versions. The rest of the pipeline then sees row-count drift or different
380
+ keys appearing in downstream joins.
381
+
382
+ Always add a deterministic tiebreaker to the `ORDER BY` (a primary key, a
383
+ surrogate id, or any column guaranteed unique within the partition):
384
+ ```sql
385
+ -- Wrong: ties produce different "top 20" every run
386
+ SELECT * FROM standings
387
+ ORDER BY points DESC
388
+ LIMIT 20
389
+
390
+ -- Right: tie on points falls back to driver_id
391
+ SELECT * FROM standings
392
+ ORDER BY points DESC, driver_id ASC
393
+ LIMIT 20
394
+
395
+ -- Same fix inside QUALIFY / window-row-number patterns:
396
+ QUALIFY ROW_NUMBER() OVER (
397
+ PARTITION BY season ORDER BY points DESC, driver_id ASC
398
+ ) <= 20
399
+ ```
400
+ If you can't think of a tiebreaker column, the model probably doesn't yet
401
+ have a unique key — fix that first.
130
402
 
131
403
  ## Common Mistakes
132
404
 
@@ -138,6 +410,7 @@ Use `altimate-dbt children` and `altimate-dbt parents` to verify the DAG is inta
138
410
  | Creating a staging model with JOINs | Staging = 1:1 with source. JOINs belong in intermediate or mart |
139
411
  | Not checking existing naming conventions | Read existing models in the same directory first |
140
412
  | Using `SELECT *` in final models | Explicitly list columns for clarity and contract stability |
413
+ | `COUNT(*)` over a `LEFT JOIN` — counts unmatched parent rows as if they had one child (e.g. a `dim_parent LEFT JOIN fct_child` with no matching children still yields one row, so `COUNT(*) = 1` instead of `0`) | Use `COUNT(<child_key>)` or `COUNT(CASE WHEN <child_key> IS NOT NULL THEN 1 END)`. If you intended to exclude unmatched parents, switch to `INNER JOIN`. Same trap applies to `SUM`, `AVG`, etc. when the unmatched side contributes a "ghost" `NULL` row |
141
414
 
142
415
  ## Reference Guides
143
416
 
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: dbt-pr-review
3
+ description: Cloudflare-style AI code review for dbt/SQL pull requests. Produces a signed APPROVE/COMMENT/REQUEST_CHANGES verdict where every blocking finding is backed by a deterministic engine call — column-lineage blast radius, query equivalence, PII classification, and A–F grade. Use to review a dbt PR or the working-tree changes before merge.
4
+ ---
5
+
6
+ # dbt PR Review
7
+
8
+ ## Requirements
9
+ **Agent:** `reviewer` (read-only) — also works from `analyst`/`builder`.
10
+ **Tools used:** `dbt_pr_review` (primary), `impact_analysis`, `altimate_core_equivalence`, `altimate_core_check`, `lineage_check`, read-only `git`.
11
+
12
+ ## When to Use This Skill
13
+
14
+ Use when the user wants to:
15
+ - Review a dbt pull request (changed models) before merge
16
+ - Get a single verdict (APPROVE / COMMENT / REQUEST_CHANGES) with evidence
17
+ - Understand the downstream blast radius of a model/column change
18
+ - Check whether a "refactor" actually preserves results (query equivalence)
19
+ - Catch PII exposure, contract breaks, or warehouse-cost anti-patterns pre-merge
20
+
21
+ ## What makes this different from a generic AI reviewer
22
+
23
+ Generic reviewers read the diff as text and guess. This review is backed by the
24
+ Rust core: every blocking finding carries a deterministic proof (an equivalence
25
+ counterexample, a downstream-model list, a PII classification). The verdict is
26
+ **signed** into a replayable envelope keyed to the dbt manifest.
27
+
28
+ ## Workflow
29
+
30
+ 1. **Run the verdict engine.** Call `dbt_pr_review` once:
31
+ - `dbt_pr_review({})` reviews the working tree against `origin/main`.
32
+ - `dbt_pr_review({ base: "origin/main", head: "HEAD", manifest_path: "target/manifest.json" })`
33
+ for an explicit PR range.
34
+ - The tool reads `.altimate/review.yml` for the per-repo rubric and `mode`.
35
+
36
+ 2. **Read the signed envelope.** It contains the verdict, a risk tier
37
+ (trivial / lite / full), and findings grouped by severity
38
+ (critical / warning / suggestion), each with engine evidence.
39
+
40
+ 3. **Present the verdict** exactly as returned. Group findings by severity.
41
+ If the run is **degraded** (no manifest/warehouse), state that lineage,
42
+ equivalence, and data-impact were NOT verified — it is a lint-only review.
43
+
44
+ 4. **Respect the safety invariant.** An UNDECIDABLE equivalence result is a
45
+ WARNING, never a block. Never claim a refactor is unsafe when equivalence
46
+ could not be decided — recommend a data-diff instead.
47
+
48
+ ## Configuration (`.altimate/review.yml`)
49
+
50
+ ```yaml
51
+ mode: comment # comment (never blocks) | gate (blocks on REQUEST_CHANGES)
52
+ severityThreshold: suggestion
53
+ manifestPath: target/manifest.json
54
+ dialect: snowflake
55
+ reviewers: [] # empty = tier defaults; or pin lanes e.g. [lineage_breakage, semantic_change]
56
+ exclude:
57
+ - models/legacy/**
58
+ rubric:
59
+ blockOn: [lineage_breakage, contract_violation, pii_exposure, semantic_change]
60
+ warningPatternThreshold: 3
61
+ thresholds:
62
+ warehouseCostMinRows: 1000000
63
+ ```
64
+
65
+ ## Verdict rubric (defaults)
66
+
67
+ - **REQUEST_CHANGES** — any blocking-category `critical` (broken lineage with
68
+ downstream consumers, contract violation, PII exposure, proven non-equivalent
69
+ rewrite), or ≥3 warnings (risk pattern).
70
+ - **COMMENT** — only suggestions, or a single non-blocking warning.
71
+ - **APPROVE** — no findings.
72
+
73
+ In `comment` mode (default), REQUEST_CHANGES is posted as comments rather than
74
+ blocking the merge. Switch to `gate` per-repo once you trust the false-positive rate.
@@ -0,0 +1,146 @@
1
+ ---
2
+ name: dbt-schema-verify
3
+ applyPaths:
4
+ - "dbt_project.yml"
5
+ - "**/dbt_project.yml"
6
+ description: |
7
+ REQUIRED after building or modifying ANY dbt model that has columns declared
8
+ in `schema.yml` / `_models.yml`. Run `altimate-dbt schema-verify --model
9
+ <name>` to diff actual columns against the spec, and treat any `mismatch`
10
+ verdict as "not done."
11
+
12
+ The most common reason "the build is green but the tests still fail" is
13
+ that the model produces the right *data values* in the wrong *column
14
+ shape* — extra columns, missing columns, wrong order, wrong types. Many
15
+ dbt equality tests grade the column tuple `(name, type, position)`
16
+ exactly, and the agent's prior bias is to add "helpful" extras
17
+ (`p1`/`p2`/`p3` rank breakdowns, name-resolved variants, lineage
18
+ metadata) or reorder columns "more logically." Both break the contract.
19
+
20
+ This skill enforces the mechanical check that catches those bugs before
21
+ declaring done. Use it before declaring any model task complete.
22
+ ---
23
+
24
+ # dbt schema-verify
25
+
26
+ ## When to invoke this skill — every time
27
+
28
+ Run `altimate-dbt schema-verify --model <name>` before declaring any of the
29
+ following tasks complete:
30
+
31
+ - Creating a new dbt model that has (or will have) a `schema.yml` entry
32
+ - Modifying an existing model whose `schema.yml` declares columns
33
+ - Refactoring a CTE into its own intermediate model
34
+ - Renaming columns or changing their order
35
+ - Changing materialization config in a way that re-creates the table
36
+ - Any task that says "match the schema", "produce these columns", "the
37
+ output should have columns X, Y, Z", or references a `_models.yml`
38
+ - Any task with `AUTO_*_equality` or `AUTO_*_existence` tests on a model
39
+
40
+ If the task touched N models, run schema-verify on **all N of them**, not
41
+ just the last one. A `build` is not a verify.
42
+
43
+ ## How to run it
44
+
45
+ ```bash
46
+ altimate-dbt schema-verify --model <name>
47
+ ```
48
+
49
+ **Note**: `altimate-dbt build --model <name>` already runs schema-verify
50
+ automatically after a successful build and includes the verdict in its
51
+ response under a `schema_verify` field. You will see the diff in the same
52
+ result that reported the build outcome — read it there before deciding
53
+ the task is done. If you need to re-check after editing, call
54
+ `schema-verify` directly.
55
+
56
+ Returns a structured JSON result:
57
+
58
+ ```json
59
+ {
60
+ "model": "int_asana__project_user_agg",
61
+ "verdict": "mismatch",
62
+ "expected_columns": ["project_id", "users", "number_of_users_involved"],
63
+ "actual_columns": ["project_id", "users"],
64
+ "columns_extra": [],
65
+ "columns_missing": ["number_of_users_involved"],
66
+ "columns_reordered": [],
67
+ "type_mismatches": []
68
+ }
69
+ ```
70
+
71
+ ## How to read the verdict
72
+
73
+ | verdict | meaning | what to do |
74
+ |---|---|---|
75
+ | `match` | actual columns match the spec exactly (case-insensitive on names) | DONE — proceed |
76
+ | `mismatch` | one or more of `columns_extra`, `columns_missing`, `columns_reordered`, `type_mismatches` is non-empty | NOT DONE — read the diff, fix the model SQL, rebuild, re-run schema-verify |
77
+ | `no-spec` | the model has no columns declared in `schema.yml` | DONE for shape-fidelity purposes — no contract to verify against |
78
+
79
+ ## How to act on a `mismatch`
80
+
81
+ For each non-empty list, the fix is mechanical:
82
+
83
+ | Field | What it means | What to change in the model SQL |
84
+ |---|---|---|
85
+ | `columns_extra` | columns in your model NOT in the spec | REMOVE them from the `SELECT` |
86
+ | `columns_missing` | columns in the spec NOT in your model | ADD them to the `SELECT` (compute them, or rename an existing column if you used a synonym) |
87
+ | `columns_reordered` | columns present in both but at different positions | REORDER the columns in your `SELECT` to match the spec's order |
88
+ | `type_mismatches` | declared `data_type` in spec disagrees with the warehouse's reported type | CAST in the `SELECT` or change the upstream source |
89
+
90
+ Then run `altimate-dbt build --model <name>` again, then re-run
91
+ `altimate-dbt schema-verify --model <name>` until verdict is `match`.
92
+
93
+ ## Iron Rules
94
+
95
+ 1. **The verdict is the source of truth, not your inspection.** Reading the
96
+ columns yourself and concluding "looks right to me" does not count.
97
+ Run the command and read its output.
98
+ 2. **A `mismatch` is "not done", even if the build is green.** dbt build
99
+ only proves the SQL compiled and ran without errors. It does not prove
100
+ the column shape is correct. Equality tests grade shape AND values.
101
+ 3. **Do not reinterpret the spec to make the model right.** The spec is
102
+ the contract. If the spec lists `supplier_company` and your model has
103
+ `supplier_id`, the answer is to fix your model, not to argue that
104
+ `supplier_id` is more useful.
105
+ 4. **Run schema-verify on every model touched, not just the last one.**
106
+ The most common "almost-pass" is N-1 models passing and the Nth one
107
+ silently failing on column shape. Walk the list.
108
+ 5. **Skip only on `no-spec`.** Do not skip on the grounds that the model
109
+ is small, or trivial, or "obvious." The spec is small only because
110
+ the dbt project author already curated it.
111
+
112
+ ## Fallback when altimate-dbt is unavailable
113
+
114
+ If `which altimate-dbt` returns nothing, do the same diff by hand:
115
+
116
+ ```bash
117
+ # 1. Read expected columns from any YAML spec under models/
118
+ # dbt allows any .yml filename; common patterns include schema.yml,
119
+ # _models.yml, models.yml, sources.yml, etc.
120
+ cat models/**/*.yml | grep -A 50 "name: <name>" # or: yq eval '...' models/**/*.yml
121
+
122
+ # 2. Read actual columns from the materialized table
123
+ dbt show --select <name> --limit 0
124
+ ```
125
+
126
+ Compare the two ordered lists. Produce the same four-bucket diff
127
+ (`columns_extra`, `columns_missing`, `columns_reordered`,
128
+ `type_mismatches`) in your head, and apply the same fix logic. The
129
+ mechanics don't change; only the tool name does.
130
+
131
+ ## What this skill does NOT cover
132
+
133
+ - **Value-level correctness** — passing schema-verify only proves shape;
134
+ whether the *values* in each column are right is a separate check
135
+ (`altimate-dbt test` + dbt unit tests). Generate unit tests with the
136
+ `dbt-unit-tests` skill when the model has non-trivial transformation
137
+ logic.
138
+ - **Row count** — schema-verify compares columns, not rows. If a refactor
139
+ drops rows that should be preserved (common when extracting a CTE into
140
+ its own model — see `dbt-develop`'s "Refactoring a CTE into its own
141
+ model" section), schema-verify will pass while equality tests fail.
142
+ Check row counts separately.
143
+ - **Custom tests** — `check_*` and other non-AUTO tests check
144
+ task-specific business rules, not column shape. schema-verify can pass
145
+ while a custom test fails. Read the custom test SQL to understand
146
+ what's being asserted.
@@ -32,6 +32,19 @@ description: Generate dbt unit tests automatically for any model. Analyzes SQL l
32
32
  3. **Use sql format for ephemeral models.** Dict format fails silently for ephemeral upstreams.
33
33
  4. **Never weaken a test to make it pass.** If the test fails, the model logic may be wrong. Investigate before changing expected values.
34
34
  5. **Compile before committing.** Always run `altimate-dbt test --model <name>` to verify tests compile and execute.
35
+ 6. **Mock data MUST exercise the failure modes of every SQL construct in the model.** A unit test that only covers the happy path validates that the model handles easy inputs — it does not validate correctness. Before writing `given:` rows, list every SQL construct in the model and the boundary case it can mishandle, then ensure at least one mock row triggers each. Universal cases to always cover when the construct appears:
36
+ - **`LEFT JOIN` / `LEFT OUTER JOIN`** → at least one parent row with **no matching child** (catches `COUNT(*)` phantom rows, `SUM` over `NULL`, fan-out / dropout)
37
+ - **`INNER JOIN`** → at least one parent row whose child is filtered out by the JOIN condition (catches missing rows)
38
+ - **`COUNT(*)` / `COUNT(<col>)`** → row where the counted column is `NULL` (catches `COUNT(*)` vs `COUNT(col)` divergence)
39
+ - **`NULLIF(x, y)`** → row where `x = y` (so the result is `NULL`, exercising downstream `NULL`-handling)
40
+ - **`/` division** → row where the denominator is `0` or `NULL`
41
+ - **`CASE WHEN`** → at least one row matching each branch, including the implicit `ELSE NULL` if no explicit `ELSE` is set
42
+ - **`COALESCE` / `IFNULL`** → row where every argument is `NULL`
43
+ - **Window functions (`OVER`)** → a partition of size 1 (single-row group exercises rank/first/last edge cases), a row at the partition boundary, and a tie-break row (two rows with the same ORDER BY key)
44
+ - **Date arithmetic / date spines** → a row at the start of range, end of range, and a gap day with no events
45
+ - **Aggregations with `GROUP BY`** → at least one group of size 1 (often masks fan-out bugs) and one group whose key is `NULL`
46
+ - **Incremental merge keys** → both an "insert" row and an "update" row matching an existing key
47
+ If you can't think of a failure mode for a construct, you don't yet understand it well enough to test it — read the SQL again before guessing inputs.
35
48
 
36
49
  ## Core Workflow: Analyze -> Generate -> Refine -> Validate -> Write
37
50