Skip to content

docs(skills): add ol-dbt-migration-validation, and fix ol-dbt-local-dev's incremental rule - #2659

Open
quazi-h wants to merge 18 commits into
mainfrom
feat/ol-dbt-migration-validation-skill
Open

quazi-h wants to merge 18 commits into
mainfrom
feat/ol-dbt-migration-validation-skill

Conversation

@quazi-h

@quazi-h quazi-h commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

What are the relevant tickets?

Supports epic #2072 (dimensional-layer mart/reporting migration). Related: #2407 / DBT_WAREHOUSE_CI_QA_SPEC.md, which specs the automated CI side of the same problem.

Description (What does it do?)

Adds skills/data/ol-dbt-migration-validation, and corrects one rule in the existing ol-dbt-local-dev skill that turned out to be actively misleading.

Why a third dbt skill. The two we have drive the tools: ol-dbt-local-dev (register, build, snapshot) and ol-dbt-fast-validation (validate / impact / diff). Between them they answer "does it compile, and what breaks downstream?". Neither answers the question a migration PR actually turns on — "is the data the same?" — which is an experimental-design problem, not a tooling one. DBT_WAREHOUSE_CI_QA_SPEC.md covers the CI automation but is explicitly not a per-PR playbook for a human.

Derived by validating #2403 (marts__micromasters_dedp_exam_gradesdim_course_run) end to end, so every number in it was measured rather than reasoned about.

The load-bearing content is which local numbers count as evidence. ol-dbt local register stores whatever metadata_location Glue reports, and for a dbt-materialized Iceberg table that is a __dbt_tmp-<uuid> path — an artifact of dbt's create-temp-then-swap. Re-measured immediately after a fresh register of staging + intermediate + dimensional on 2026-09-09:

layer views on __dbt_tmp
raw 1,388 0
staging 256 244
intermediate 159 157
dimensional 56 55
mart 29 29
reporting 30 29
external 110 110
non-raw total 640 624 (97.5%)

Registering does not clear it — this is the steady state, not a race. Such a view either 404s (loud) or silently returns the temp table's accumulated snapshots: duplicated and partially missing rows, measured at 31x on int__mitxonline__proctored_exam_grades. So an absolute row count or fill-rate percentage read through a glue__ view is not evidence, and some have already been quoted in PR bodies as though they were. A difference between two locally built sides of the same registration is evidence, because the pollution cancels. The whole skill is organised around that distinction: register once, build both sides in one invocation, compare.

Three traps it records, each of which cost real time.

  1. --full-refresh is mandatory when the model under test is incremental — and this is the ol-dbt-local-dev rule being amended. The old wording said to "reserve --full-refresh for when the incremental state is stale or wrong", which can never fire: the failure mode is a state that is perfectly valid and merely does not reflect your new code. An incremental run whose key set is unchanged is a no-op that still reports OK, so a changed expression is never re-evaluated and you read the old values believing they are new. feat(#2088): migrate marts__micromasters_dedp_exam_grades to dim_course_run #2403's own documented test command hit exactly this — dim_course_run merged in 0.11s and never re-derived the column under test, for two months. Reframed as iterating vs concluding, which is the distinction that actually decides it.

  2. ~/.ol-dbt/local.duckdb is shared by every worktree and session on the machine. Mid-validation, a concurrent session rebuilt dim_course_run from main and semester went from 4,513/4,513 populated to 12/4,513, with no warning from either side. This is a second, independent reason to materialize both comparison sides in one invocation — the feat(#2088): migrate marts__micromasters_dedp_exam_grades to dim_course_run #2403 result survived only because both marts were already physical tables.

  3. dbt defaults to --indirect-selection=eager, which selects relationships_* tests owned by other models and compares your local build against production Glue views. Measured on feat(#2088): migrate marts__micromasters_dedp_exam_grades to dim_course_run #2403's two changed models: 20 tests under eager, 12 under buildable, 11 under cautious — and the 9 that eager adds were all cross-model, including a 42.7M-row tfact_grade scan and every failure the PR body had been documenting as expected noise. The skill also records why expected-failure counts should never go in a PR body: feat(#2088): migrate marts__micromasters_dedp_exam_grades to dim_course_run #2403 documented 87/74/13 orphans and the same tests produced 861/178/150 plus an undocumented fourth failure, because the numbers depend on when you registered, not on the change.

Also included: the per-column multiset diff (localises a difference to one column, needs no join key), accepting on the distinct functional mapping rather than the whole row, and the rule that a column empty on both sides is unverified, not passing — which is exactly how passing_grade looked clean on an earlier #2403 diff while being just as broken as semester.

Registers the skill in agent-config.toml and adds a migration profile alongside the existing dbt one.

How can this be tested?

No code changes — two Markdown files and a TOML registration. Nothing executes.

Verify the registration is well-formed:

uv tool install 'agent-config-kit[cli]'
agent-kit validate agent-config.toml
agent-kit apply agent-config.toml --profile migration --dry-run

Frontmatter conforms to the Agent Skills specname matches the directory (ol-dbt-migration-validation), description states what it covers and when to trigger, license and metadata.category match the two sibling skills.

To sanity-check the central factual claim yourself, register any dbt-built layer and count how many views point at a temp location:

-- against ~/.ol-dbt/local.duckdb
select regexp_extract(glue_database, 'production_([a-z]+)$', 1) as layer,
       count(*) as views,
       sum(case when metadata_location like '%__dbt_tmp%' then 1 else 0 end) as on_dbt_tmp
from _glue_source_registry
group by 1 order by 3 desc;

And to reproduce the test-selection numbers without building anything:

cd src/ol_dbt && DBT_PROFILES_DIR=$(pwd) \
  dbt ls --select dim_course_run marts__micromasters_dedp_exam_grades \
    --resource-type test --indirect-selection=cautious -t dev_local | wc -l   # 11
# ...same with --indirect-selection=eager                                     # 20

Additional Context

The amendment to ol-dbt-local-dev is the part most worth a careful read. That skill is @tmacey's (#2425) and Rachel extended it in #2469; I have changed one of its ## Rules bullets and added a second. I could have left the new skill to disagree with it, but a reader hitting two skills that contradict each other on --full-refresh is worse off than before, and the old rule is the direct cause of #2403's instructions verifying nothing since July. Happy to split that hunk out if you would rather it landed separately.

Deliberate overlap. The new skill repeats ol-dbt-fast-validation's primary-key guidance instead of only linking to it. A reader mid-migration should not have to hop skills for the one step that silently invalidates the entire comparison if done wrong (a non-unique key pairs rows many-to-many and reports join artifacts as mismatches — one measured case read 17,782 unmatched of 20,908 rows, all artifact).

This documents a limitation rather than waiting on it. The __dbt_tmp root cause is tracked internally and is not fixed; when it is, the skill's step 5 can assert considerably more and the table above becomes historical. It names that dependency inline so the next reader knows which parts expire.

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings September 9, 2026 20:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The playbook contains contradictory commands and overstates what local comparisons and row-count parity prove.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds guidance for validating dbt migrations against local production-backed data.

Changes:

  • Adds a migration-validation skill and profile.
  • Clarifies incremental/full-refresh guidance.
  • Registers the new skill in agent configuration.
File summaries
File Description
skills/data/ol-dbt-migration-validation/SKILL.md Adds the validation playbook.
skills/data/ol-dbt-local-dev/SKILL.md Updates local-development rules.
agent-config.toml Registers the skill and migration profile.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 5
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread skills/data/ol-dbt-local-dev/SKILL.md Outdated
Comment thread skills/data/ol-dbt-migration-validation/SKILL.md Outdated
Comment thread skills/data/ol-dbt-migration-validation/SKILL.md Outdated
Comment thread skills/data/ol-dbt-migration-validation/SKILL.md Outdated
Comment thread skills/data/ol-dbt-migration-validation/SKILL.md Outdated

@blarghmatey blarghmatey left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, but worth addressing copilot's comments before merging.

quazi-h added a commit that referenced this pull request Sep 11, 2026
Copilot review on #2659 found five overclaims. All five are valid.

- The incremental trap was framed as "an unchanged key set makes the run a
  no-op". dbt does execute the model SQL; what varies is which rows the
  model's own is_incremental() predicate reselects. Reframed around the
  predicate, with the two real failure modes (excluded rows keep old-code
  values; a predicate that matches everything yields a true no-op) and the
  SCD2 consequence that dim_course_run appends a second generation rather
  than replacing the first. Stopped asserting a root cause for #2403's 0.11s
  no-op that was never established — the lesson is not to reason from the
  predicate at all.
- "Both sides read the identical polluted source and the duplication
  cancels" is false exactly where a #2072 migration re-points ref(), which
  is every migration PR. override_ref resolves each unbuilt ref() to its own
  glue__ view with independent __dbt_tmp pollution, and joins/filters mean
  the two do not offset. Added: build the divergent upstreams locally, or
  label the result unverified source noise rather than accepting/rejecting
  on the registration artifact the section warns about.
- Step 3 used `dbt build`, contradicting the cautious-test section below it
  and risking an unmaterialized comparison side when a test fails. Now
  `dbt run`.
- "Row-count delta 0 means the grain is intact" — dropped rows offset by
  duplicates net to zero too. Now count parity, with a
  count(*) = count(distinct key) check on both relations.
- Step 6's distinct-pair assertion cancels duplication but not rows a
  polluted view is missing; scoped accordingly.

Also propagated the incremental correction to ol-dbt-local-dev, including
its two worked examples, which both rebuilt without --full-refresh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quazi-h

quazi-h commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Ran the corrections against dbt instead of leaving them derived

The five fixes in eba58e6 were reasoned from reading dim_course_run.sql, override_ref.sql and git history. I've now executed them on dbt 1.12.4 / dev_local (DuckDB). All five hold. Measurements added in 562e4d3.

The incremental claim — the original skill was empirically wrong

Built three purpose-made incremental models (isolated verify schema, deleted afterwards) to separate the two competing theories. The decisive case is the third:

model predicate key set across runs expression result after incremental run
_verify_watermark pk > max(pk) unchanged 1..5 V1 → V2 all rows still V1, OK in 0.09s
_verify_nofilter none unchanged 1..5 V1 → V2 all rows V2, OK in 0.07s
_verify_scd2 SCD2 not exists V1 → V2 6 rows: 3 expired V1 + 3 current V2

Rows 1 and 2 have an identical unchanged key set and opposite outcomes. That falsifies "an incremental run whose key set is unchanged is a no-op" directly, and confirms your "delete+insert can replace an existing key when the model emits it." What separates them is only the predicate — which is what the text now says.

Row 3 confirms the SCD2 behaviour I added: the prior row is expired and retained, the new derivation appended with a fresh effective_date. A plain select * returns both generations.

And the genuine no-op: re-running _verify_scd2 with the label unchanged reselected nothing — OK in 0.10s, still 6 rows. That is the #2403 signature reproduced.

dbt build → skip: confirmed, plus a nuance that makes it worse

3 of 3 SKIP relation main_verify._verify_downstream after an upstream test failed. Your point was right.

But it's intermittent here: dbt_project.yml sets +error_if: ">10", so 4 failing rows → WARN and the downstream still builds, while 24 → FAIL + SKIP. dbt build therefore appears to work until a test crosses the threshold. Documented.

Test selection on dim_course_run: eager 17 / buildable 8 / cautious 7. All 10 that eager adds are relationships_*, and 6 are owned by other models (tfact_grade, tfact_enrollment, tfact_certificate, dim_product, and both bridge_*) — exactly the mechanism.

The cancellation fix — this is the one the data argues hardest for

override_ref resolution, from compiled SQL:

from "local"."main_dimensional"."dim_course_run" as built_one              -- built locally
cross join "local"."main"."glue__ol_warehouse_production_dimensional__dim_user"  -- unbuilt -> Glue view

Then I measured duplication across all 29 registered dim_ views in one registration:

view rows distinct pk ratio
dim_ocw_resource 179,863 2,908 61.85x
dim_course_run 10,884 8,508 1.28x
dim_course 4,321 4,071 1.06x
24 others 1.00x
dim_discussion_topic, dim_video 404, pointer rot

A 1.00x view and a 61.85x view in the same layer and the same registration. Duplication can only cancel between identical views — which is precisely what a migration that re-points ref() does not have. Your objection was correct and the old wording would have licensed accepting or rejecting a migration on that artifact.

Incidentally, the view the skill cited for 31x now 404s — the __dbt_tmp path was cleaned up since 2026-09-09. That is the pointer-rot half of the same claim, observed live.

Also re-measured

Pollution table five days on: 651/667 non-raw views (97.6%), every layer but staging identical to the 2026-09-09 figures — it is the steady state, as claimed.

Scope

The grain fix (count parity ≠ grain integrity) is arithmetic, not a dbt behaviour; confirmed with a 5-row counterexample where the delta is 0 while one key is dropped and another fanned out.

I did not rebuild the real dim_course_run or any production-mirror model — all dbt writes went to a throwaway verify schema, now dropped. So this verifies the semantics the skill asserts, not a re-run of #2403 itself.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The documented commands contain invocation errors and omit the old divergent dependency path required for a controlled comparison.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

skills/data/ol-dbt-migration-validation/SKILL.md:158

  • Repository local-development commands invoke dbt through uv run (docs/LOCAL_DEV_QUICK_REF.md:17-41). Using bare dbt here can select an unrelated global executable or fail in a normal unactivated uv checkout, undermining the reproducibility of the acceptance procedure.
    skills/data/ol-dbt-migration-validation/SKILL.md:262
  • As with the build step, run dbt through the repository's uv environment; otherwise the validation may use a different dbt/adapter version from the one locked by this project or fail when the virtual environment is not activated (docs/LOCAL_DEV_QUICK_REF.md:32-35).
  • Files reviewed: 3/3 changed files
  • Comments generated: 3
  • Review effort level: Balanced (auto)

Note

Copilot is running an experiment and ran this review at Balanced.

Comment thread skills/data/ol-dbt-local-dev/SKILL.md Outdated
Comment thread skills/data/ol-dbt-migration-validation/SKILL.md Outdated
Comment thread skills/data/ol-dbt-migration-validation/SKILL.md Outdated
quazi-h added a commit that referenced this pull request Sep 15, 2026
…ld, and the generation-count claim

Second Copilot round on #2659. All three findings valid; each verified
before fixing.

The documented `ol-dbt run --select dim_user_old dim_user` cannot work.
`--select` is declared `str | None` (commands/run.py), so the second model
binds to the SUBCOMMAND positional: `Invalid value "dim_user" for
SUBCOMMAND. Choose from: "build", "run", "test"` -- reproduced, it never
reaches dbt. Quoted it, and noted the asymmetry that makes this easy to get
wrong: `ol-dbt diff`'s -k/--exclude-columns are `list[str]` and do accept
repeated values, while `run --select` does not.

The canonical step-3 command contradicted the requirement this PR added in
the header section. It selected the new dimensional upstream but not the
old path's, so `<model>_pre` still resolved its divergent ref to an
independently polluted Glue view -- the uncontrolled comparison the header
exists to prevent. Now selects both divergent ancestor paths, prefixed by a
ref-diff to find them, with measured selector costs (`+dim_course_run` = 53
models, `1+` = 7). Verified dbt accepts mixed plain and graph-operator
selectors in one --select.

"Divide to get the generation count" was wrong. The ratio is average key
multiplicity; it would equal a generation count only if every snapshot held
the same key set, and these snapshots drop rows as well as duplicate them.
Own measurements make the point: 1.28x and 61.85x are not counts of
anything. Reframed as a pollution smell, with an explicit warning not to
divide by it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quazi-h
quazi-h requested a balanced review from Copilot September 15, 2026 18:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The playbook has unreliable ref discovery, incorrect key guidance, and insufficient protection against shared DuckDB races.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

skills/data/ol-dbt-migration-validation/SKILL.md:359

  • This recommendation is reversed: ol-dbt diff's per-column comparison joins on the supplied key, so a non-unique key (or nullable scalar key) is exactly what can fan out or mispair rows and corrupt the mismatch rate. Conversely, the top-level “Rows without an exact match” count comes from compare_relations, where the key is used only for ordering (src/ol_dbt_cli/ol_dbt_cli/commands/diff.py:415-425,450-480). If the key is not proven, skip the keyed diff and rely on the keyless multiset check in step 5(b).
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Balanced (auto)

Note

Copilot is running an experiment and ran this review at Balanced.

Comment thread skills/data/ol-dbt-migration-validation/SKILL.md Outdated
Comment thread skills/data/ol-dbt-migration-validation/SKILL.md Outdated
quazi-h and others added 7 commits September 15, 2026 14:53
…n PRs

The two existing dbt skills drive the tools (`ol-dbt-local-dev` for
register/build, `ol-dbt-fast-validation` for validate/impact/diff) but neither
answers the question a migration PR actually turns on: is the data the same?
This adds the acceptance procedure, derived from validating #2403.

The load-bearing part is which local numbers are trustworthy. A fresh register
of staging/intermediate/dimensional on 2026-09-09 still put 624 of 640 non-raw
Glue views on `__dbt_tmp` metadata locations, which return duplicated and
partially-missing rows while the build succeeds. So an absolute row count or
fill rate read through a `glue__` view is not evidence, and several have already
been quoted in PR bodies as though they were. A difference between two locally
built sides of the SAME registration is evidence, because the pollution cancels.
The skill is organised around that distinction: register once, build both sides
in one invocation, compare.

Also records three traps that each cost real time:

- `--full-refresh` is mandatory when the model under test is incremental.
  #2403's own documented test command omits it, and `dim_course_run` therefore
  merged in 0.11s without re-deriving the column under test. This contradicts
  `ol-dbt-local-dev`'s "prefer incremental" rule, which is right for iterating
  and wrong for validating; the new skill says so explicitly rather than
  silently disagreeing.
- The per-column multiset diff needs no join key at all and localises a
  difference to one column in a single pass.
- A column unpopulated on both sides is unverified, not passing.

Registers the skill in agent-config.toml and adds a `migration` profile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… test-selection traps

Follow-up to the initial draft, from validating PR #2403 end to end.

Amends `ol-dbt-local-dev`'s rule rather than leaving the new skill to contradict
it. The old wording — "reserve --full-refresh for when the incremental state is
stale or wrong" — cannot fire, because the failure mode it needs to catch is a
state that is perfectly valid and merely does not reflect your new code. An
incremental run whose key set is unchanged is a no-op that still reports OK, so
a changed expression is never re-evaluated and you read the old values believing
they are new. #2403's own documented test command hit exactly this:
dim_course_run merged in 0.11s and never re-derived the column under test, for
two months. The rule is now framed as iterating vs concluding, which is the
distinction that actually decides it.

Adds two traps neither existing skill covers:

- ~/.ol-dbt/local.duckdb is shared by every worktree and session on the machine.
  A concurrent session rebuilt dim_course_run from main mid-validation and
  semester went from 4,513/4,513 populated to 12/4,513 with no warning from
  either side. This is the second independent reason to materialize both
  comparison sides in ONE invocation: the #2403 result survived only because both
  marts were already physical tables.
- dbt defaults to --indirect-selection=eager, which pulls in relationships_*
  tests owned by other models and compares your local build against production
  Glue views. Measured 20 tests eager vs 11 cautious on #2403's two models; the 9
  difference were all cross-model, including a 42.7M-row tfact_grade scan and
  every failure the PR body documented as expected. Also records why
  expected-failure counts do not belong in a PR body — #2403 documented 87/74/13
  and the same tests produced 861/178/150 plus an undocumented fourth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot review on #2659 found five overclaims. All five are valid.

- The incremental trap was framed as "an unchanged key set makes the run a
  no-op". dbt does execute the model SQL; what varies is which rows the
  model's own is_incremental() predicate reselects. Reframed around the
  predicate, with the two real failure modes (excluded rows keep old-code
  values; a predicate that matches everything yields a true no-op) and the
  SCD2 consequence that dim_course_run appends a second generation rather
  than replacing the first. Stopped asserting a root cause for #2403's 0.11s
  no-op that was never established — the lesson is not to reason from the
  predicate at all.
- "Both sides read the identical polluted source and the duplication
  cancels" is false exactly where a #2072 migration re-points ref(), which
  is every migration PR. override_ref resolves each unbuilt ref() to its own
  glue__ view with independent __dbt_tmp pollution, and joins/filters mean
  the two do not offset. Added: build the divergent upstreams locally, or
  label the result unverified source noise rather than accepting/rejecting
  on the registration artifact the section warns about.
- Step 3 used `dbt build`, contradicting the cautious-test section below it
  and risking an unmaterialized comparison side when a test fails. Now
  `dbt run`.
- "Row-count delta 0 means the grain is intact" — dropped rows offset by
  duplicates net to zero too. Now count parity, with a
  count(*) = count(distinct key) check on both relations.
- Step 6's distinct-pair assertion cancels duplication but not rows a
  polluted view is missing; scoped accordingly.

Also propagated the incremental correction to ol-dbt-local-dev, including
its two worked examples, which both rebuilt without --full-refresh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ions

Ran the corrected claims against dbt 1.12.4 on dev_local rather than
leaving them derived from reading the source. All held. Two results were
worth recording in the skill.

__dbt_tmp duplication is per-view and heterogeneous. Across all 29
registered dim_ views in one registration: dim_ocw_resource 179,863 rows
for 2,908 distinct pks (61.85x), dim_course_run 1.28x, dim_course 1.06x,
24 others at 1.00x, and two 404ing on pointer rot. A 1.00x and a 61.85x
view in the same layer and the same registration is the empirical reason
the cancellation rule has to be scoped to shared inputs -- identical views
cancel, different views cannot. Also re-measured the pollution table five
days on: 651/667 non-raw (97.6%), every layer but staging identical,
confirming it is the steady state.

The dbt build skip is intermittent here, which is worse than consistent.
dbt_project.yml sets +error_if: ">10", so a test with <=10 failing rows
only WARNs and the downstream model still builds. Measured: 4 failing rows
-> WARN, downstream built; 24 -> FAIL, SKIP relation. So `dbt build`
appears to work until a test crosses the threshold, and then a comparison
side silently does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ld, and the generation-count claim

Second Copilot round on #2659. All three findings valid; each verified
before fixing.

The documented `ol-dbt run --select dim_user_old dim_user` cannot work.
`--select` is declared `str | None` (commands/run.py), so the second model
binds to the SUBCOMMAND positional: `Invalid value "dim_user" for
SUBCOMMAND. Choose from: "build", "run", "test"` -- reproduced, it never
reaches dbt. Quoted it, and noted the asymmetry that makes this easy to get
wrong: `ol-dbt diff`'s -k/--exclude-columns are `list[str]` and do accept
repeated values, while `run --select` does not.

The canonical step-3 command contradicted the requirement this PR added in
the header section. It selected the new dimensional upstream but not the
old path's, so `<model>_pre` still resolved its divergent ref to an
independently polluted Glue view -- the uncontrolled comparison the header
exists to prevent. Now selects both divergent ancestor paths, prefixed by a
ref-diff to find them, with measured selector costs (`+dim_course_run` = 53
models, `1+` = 7). Verified dbt accepts mixed plain and graph-operator
selectors in one --select.

"Divide to get the generation count" was wrong. The ratio is average key
multiplicity; it would equal a generation count only if every snapshot held
the same key set, and these snapshots drop rows as well as duplicate them.
Own measurements make the point: 1.28x and 61.85x are not counts of
anything. Reframed as a pollution smell, with an explicit warning not to
divide by it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… SQL that cannot parse

Self-review pass before the next review round. Nine findings, all verified
by running them.

Runnability. Every `dbt` and `ol-dbt` command in both skills is broken when
another dbt is earlier on PATH. `ol-dbt` shells out to bare "dbt"
(commands/run.py: cmd = ["dbt", subcommand, ...]) resolved through PATH, not
from the venv it lives in; a ~/Library/Python/3.9/bin/dbt (1.8.1) shadowed
the project's 1.12.4 here and every dev_local command died with "Could not
find adapter type duckdb!" -- an error that names the adapter, not the PATH.
Added a prerequisite section to ol-dbt-local-dev with the diagnosis, the
`which -a dbt` check and the `uv run --frozen` fix (--frozen so running a
command cannot rewrite uv.lock), a pointer to it from migration-validation,
and switched that skill's two dbt commands to `uv run --frozen dbt`.

SQL that cannot parse. Both step-5 blocks alias `count(*) rows`, and `rows`
is a reserved word in DuckDB -- the target these queries exist to run
against. Neither has ever run as written. Now `as n_rows`, with a note. Also
documented that a composite `count(distinct ...)` must be parenthesised as
`(a, b)`; the bare form is a binder error. Verified all four blocks parse.

Consistency. Step 1 said to register only the layers the model reads, while
step 3 now suggests `+<upstream>`, whose leaves read source() and need raw
registered -- called that out. The step-3 test command still selected
`<dimensional_model>`, which stopped matching the run command when that was
fixed last round; realigned, since drift there means testing a different set
than you compared. `-k <key cols>` invited the space-separated form step 4
says is broken; now `-k <a,b,c>`.

Smaller: "a fresh register of all three layers" described a seven-layer
table; the view cited for 31x has since flipped to 404, which is the churn
claim observed, so noted; dropped a duplicated sentence; and my own
agent-config.toml realignment was one column out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…is tuned an order of magnitude too loose

ol-dbt-local-dev told you to register, and `list-sources` warns when the registry
is ">1 day old". Both understate the problem. `register` stores the Iceberg
metadata_location Glue reports at that instant, and for a dbt-built table Glue
routinely points the canonical name at a __dbt_tmp directory that the next
production materialization swaps and deletes.

Measured while validating #2403: a registry refreshed 60 MINUTES earlier failed
with HTTP 404 reading the metadata JSON for int__micromasters__dedp_proctored_exam_grades,
killing two mart models mid-validation. Re-registering only the intermediate layer
(41 pointers moved, 118 unchanged) fixed it. Re-registering the same three layers
26 hours later moved another 336 pointers — 184 staging, 108 intermediate, 44
dimensional — which is the churn rate the one-day threshold is failing to track.

The 404 is the lucky shape. When the __dbt_tmp directory still exists but holds a
mid-build snapshot, the view returns duplicated or partial rows and nothing fails
at all: a clean run and wrong numbers, which is the dangerous case when those
numbers are about to be quoted in a PR. So the guidance is unconditional
re-registration immediately before a build, not a reaction to a visible error.

#2660 stopped __dbt_tmp tables being registered as sources in their own right but
deliberately scoped out canonical names pointing at __dbt_tmp locations (621/636
dbt-built tables at last count); that remains tracked separately. Until it lands,
freshness is the caller's responsibility, so the skill now says so.

Verified the three documented register commands run verbatim from a clean worktree
(0 errors each) and pre-commit passes on the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quazi-h
quazi-h force-pushed the feat/ol-dbt-migration-validation-skill branch from 0abde18 to 2b1dff1 Compare September 15, 2026 18:53
quazi-h and others added 3 commits September 15, 2026 14:57
…ical table is immune

Fourth Copilot round. Both findings valid, both verified before fixing.

The ref-discovery command I added last round only matched single-quoted
ref(). Measured: 13 double-quoted occurrences across 5 models, THREE OF
THEM IN reporting/ -- the layer #2072 migrates. On
reporting/data_detail_problems.sql the shipped pattern returns zero
matches, so the diff shows no divergent refs, the reader omits the
upstreams, and the comparison is uncontrolled. It fails silently and in
the direction that looks clean, which is the worst shape for this
particular command. Now a char-class pattern (portable -- a backreference
errors under ugrep) that also strips quotes and the ref() wrapper, so it
compares bare model names and re-quoting a ref between versions cannot
read as a false divergence. Verified verbatim: finds the double-quoted
refs, still surfaces the real divergence on the ac99b17 -> main
dim_course_run case, clean across 60 models.

"Table-materialized output is immune once written" contradicted this
section's own opening sentence, which says another session's dbt run will
overwrite your tables. It will. A physical table is immune to POINTER ROT,
not to another dbt run. The #2403 marts survived because the concurrent
session rebuilt dim_course_run, their upstream, and never selected the
marts -- targeting, not immunity. Replaced with that distinction plus the
operational requirement: confirm exclusive use of dev_local and measure
immediately, since one invocation narrows the window but is not atomic.
Added real isolation as the option when a result will be published --
HOME=/tmp/iso-validation redirects dev_local to a private DuckDB file
(verified; the 19GB warehouse is untouched), at the cost of a full
re-register since that database starts empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2b1dff1 (re-register before every build) and this skill's step 1
("register once, then do not touch the registry") read as opposite advice
in two skills that reference each other. They are not opposites -- they
constrain different variables -- but nothing said so, and following them in
sequence gives no way to tell.

Freshness vs stability. 2b1dff1 minimizes registration AGE: pointers rot
within the hour, and the silent form returns duplicated or partial rows
with no error. Step 1 keeps the registration CONSTANT across a comparison,
since re-registering between the two sides makes every difference drift
rather than code. Both hold simultaneously exactly because step 3 builds
both sides in one invocation -- register immediately before it.

Where they genuinely conflicted, 2b1dff1 is right and step 1 was wrong.
"Do not touch the registry" forbids the safe action in the rebuild case:
the invariant that matters is that both sides of a comparison share one
registration, not that you never re-register. Worse, a validation building
+<upstream> ancestor trees can easily outlast the hour, so the old rule had
you concluding off a registry its own skill says is rotting. Rule is now:
register immediately before the invocation that builds both sides; never
between the two sides; if you rebuild, re-register and rebuild BOTH.

Also dropped "pointer churn is roughly 90% of a layer per day". It entered
with the original commit, has no measurement recorded anywhere, and
2b1dff1's numbers contradict it -- 65% staging, 68% intermediate, 79%
dimensional over 26 hours. Replaced with those, plus the 60-minute 404 that
is the figure actually motivating the rule. Cross-referenced both
directions so the two skills visibly agree, and updated the Rules list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… number

From a SUPPRESSED Copilot comment (review body, never a thread, so the
thread-based workflow never surfaced it). Verified in the macros and then
by experiment; Copilot is right and step 4 said the opposite.

Step 4 claimed a weak key inflates "rows without an exact match" and that
you should therefore trust the per-column mismatch rate instead. Both
halves are wrong, and they are wrong in the direction that matters:

- compare_column_values emits `full outer join b_query on a_query.<pk> =
  b_query.<pk>` (audit_helper compare_column_values.sql:42), so the
  PER-COLUMN RATE is the key-sensitive figure.
- compare_queries computes unmatched rows with EXCEPT on full row content;
  primary_key appears only in an `order by` (compare_queries.sql:102). The
  UNMATCHED COUNT does not depend on the key at all. Sample rows come from
  the same EXCEPT branch, so they are key-independent too.

Measured 2026-09-15 to be sure: two byte-identical 4-row tables whose key
repeated once reported `0 unmatched row-side(s)` (correct) alongside
`val: 33.33% (2 rows)` — pure 2x2 fan-out on the duplicated key. The old
guidance would have you report a 33% regression on identical data, in
exactly the situation it exists to help with: an unproven key.

Replaced with a table of which figure depends on the key, the measurement,
and the right fallback: if the key is not proven, do not run the keyed diff
at all -- use the keyless multiset check in 5(b). Also corrected the
attribution of the 17,782/20,908 case, which was source duplication (the
16.7x documented elsewhere in this skill), not join noise from a guessed
key, and cross-referenced the tracked NULL-key task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quazi-h
quazi-h requested a balanced review from Copilot September 15, 2026 19:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Key discovery misses nested mart schemas, and registration failures can leave validation using stale inputs.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 3
  • Review effort level: Balanced (auto)

Note

Copilot is running an experiment and ran this review at Balanced.

Comment thread skills/data/ol-dbt-local-dev/SKILL.md
Comment thread skills/data/ol-dbt-migration-validation/SKILL.md
Comment thread skills/data/ol-dbt-migration-validation/SKILL.md Outdated
…obbing for schema YAML

Fifth Copilot round, three findings, all valid and all verified in the code
before fixing.

REGISTER REPORTS SUCCESS WITH FAILED TABLES. `ol-dbt local register` catches
per-table failures, counts them, prints `✗ Errors: N`, and still exits 0 --
there is no raise or non-zero exit on that path in local_dev.py. A table that
fails to re-register keeps its PREVIOUS view, so the pointer you re-registered
to refresh can still be the stale one, and an unattended run proceeds against
it. That silently defeats the freshness rule this PR just added. Step 1 now
loops the three layers and gates the build on `✗ Errors: 0` (verified against
real register output, and the summary string confirmed by a --dry-run);
ol-dbt-local-dev gets the same warning at its register block.

THE SCHEMA-YAML GLOB DOES NOT RECURSE. `models/**/_<area>__models.yml` matches
ONE directory level in bash without globstar, while 31 of 35 model schema
files sit two or three deep -- so it expands to nothing for most marts, which
are the primary migration scope. It happens to work in zsh, which is what
makes it a good trap. Compounding it, the filename convention is not uniform:
dim_course_run's schema is _dim_course_run.yml, so no glob shape finds both.
Replaced with `dbt ls --output-keys patch_path`, which is authoritative for
either shape; verified verbatim on one model of each.

While there: the skill named only dbt_expectations.expect_compound_columns_to_be_unique,
but dim_course_run -- its own motivating model -- uses
dbt_utils.unique_combination_of_columns (19 occurrences across 9 files vs 231
across 32). Both are now named, with dim_course_run's actual key spelled out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Several documented commands have path, registration, and control-flow errors that can invalidate or prevent validation.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

Previously missed (3) — in code that hasn't changed since the last review.

skills/data/ol-dbt-migration-validation/SKILL.md:210

  • These arguments are model basenames, but the files created in step 2 live under src/ol_dbt/models/...; at this point the procedure has not changed into that directory. Following the steps from the repository root makes both grep calls fail and the two empty outputs appear identical, silently omitting all divergent upstreams. Pass the actual paths created in step 2.
    skills/data/ol-dbt-local-dev/SKILL.md:27
  • The new prerequisite says every command below must use uv run --frozen, but most subsequent copyable examples still invoke bare ol-dbt (including run and diff, which invoke dbt). Those are exactly the commands that can pick up the shadowing global executable this section warns about. Either update every example or explicitly require activating .venv before following the bare-command examples.
    skills/data/ol-dbt-migration-validation/SKILL.md:180
  • A full ancestor tree does not necessarily terminate at raw sources. override_source.sql:36-53 derives the Glue database from each source's declared schema, and this repository has dimensional and reporting sources as well. Register every source layer reached by the selected graph; otherwise a valid +<upstream> build can still fail because the required non-raw Glue view was never registered.
  • Files reviewed: 3/3 changed files
  • Comments generated: 3
  • Review effort level: Balanced (auto)

Note

Copilot is running an experiment and ran this review at Balanced.

Comment thread skills/data/ol-dbt-migration-validation/SKILL.md Outdated
Comment thread skills/data/ol-dbt-migration-validation/SKILL.md
Comment thread skills/data/ol-dbt-migration-validation/SKILL.md Outdated
…istration gate actually gate

Sixth Copilot round: three threads plus three suppressed comments in the
review body. All six valid, all verified before fixing.

THE GATE DID NOT GATE. Last round's registration check used `break`, which
only leaves the loop -- the snippet then exits 0 and an unattended run
prints STOP and builds anyway. Now a script (shebang, `set -euo pipefail`)
that exits 1, with a note that pasting it interactively will close the
shell.

NO WORKING DIRECTORY WORKED. The procedure mixed repo-root paths (step 2's
git show) with a project-dir shell (step 3's cd), so two discovery commands
had no directory where they worked: from the root `dbt ls` cannot find
dbt_project.yml, from src/ol_dbt the patch_path join doubles the prefix, and
the ref-diff took basenames for files that live under src/ol_dbt/models/.
Both failure modes are silent and look clean -- two failed greps produce two
empty outputs that diff calls identical, which reads as "no divergent refs".
Stated the working directory once (repo root), anchored dbt with
--project-dir, and made the ref-diff take the paths step 2 actually creates.

I shipped that key-discovery command last round having tested a DIFFERENT
one: I ran `grep "$yml"` from src/ol_dbt and committed `grep "src/ol_dbt/$yml"`.
Every changed snippet this round was run verbatim from the repo root before
committing.

A `where` CLAUSE SCOPES WHAT A UNIQUENESS TEST PROVES. dim_course_run's is
`where: "is_current = true"`, so the key is proven for current rows only,
while an SCD2 relation accumulates several expired rows per business key all
sharing is_current = false. Demonstrated: two changes to one key give 6 rows
/ 5 distinct keys unfiltered -- reads as a fan-out, is ordinary history --
and 3/3 with the predicate applied.

Also: ancestor trees do not necessarily bottom out in raw (this project
declares dimensional and reporting sources too), so the note now says
register every source layer the selection reaches, with a command to list
them. And ol-dbt-local-dev's prerequisite told readers to use `uv run
--frozen` while every example below it was bare; it now says to activate the
venv once (verified: which dbt then resolves to the repo's 1.12.4), with the
per-command prefix as the alternative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quazi-h

quazi-h commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the three suppressed comments from this round's review body as well, in the same commit (8d30b83) — noting them here since they have no threads to reply on:

SKILL.md:210 — ref-diff took basenames. Correct, and it fails silently in the clean direction: step 2 writes those files under src/ol_dbt/models/..., so from the repo root both greps fail, two empty outputs diff as identical, and you read that as "no divergent refs" and omit the upstreams. Same root cause as the patch_path thread; both are now anchored to the repo root, which is stated once at the top of the procedure.

SKILL.md:180 — ancestor trees do not necessarily end at raw. Verified: this project declares dimensional and reporting sources alongside ol_warehouse_raw_data, and override_source derives the Glue database from each source's declared schema. The note said "register raw as well"; it now says register every source layer the selection reaches, with a command to list them:

DBT_PROFILES_DIR=src/ol_dbt uv run --frozen dbt ls --project-dir src/ol_dbt \\
  --select +<upstream> --resource-type source -t dev_local | grep '^source:'

ol-dbt-local-dev:27 — the prerequisite contradicted its own examples. Fair. I had decided to state the uv run --frozen rule once rather than prefix twenty lines, but that left every copyable example bare, including ol-dbt run and ol-dbt diff, which are exactly the ones that shell out to dbt. Taking your second option: the prerequisite now says to activate the venv once (verified — which dbt then resolves to this repo's 1.12.4), with the per-command prefix as the alternative, and says plainly that doing neither is what breaks.

Worth recording that two of this round's six findings were commands I had shipped without running as written — including one where I tested grep \"$yml\" and committed grep \"src/ol_dbt/$yml\". Every snippet changed in 8d30b83 was executed verbatim from the stated working directory before commit.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Several documented commands either fail from the required working directory or do not enforce the stated test and filtering constraints.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

skills/data/ol-dbt-migration-validation/SKILL.md:437

  • This grep scans every uniqueness test in the shared schema file rather than the selected model's YAML block. Files such as _marts__combined__models.yml contain many models and many unrelated uniqueness tests, so this output does not identify which key belongs to <model> and can lead directly to the weak-key fan-out warned about below. Scope extraction to the selected model block (and include its complete test config/where) or instruct the reader to inspect that block directly.
  • Files reviewed: 3/3 changed files
  • Comments generated: 3
  • Review effort level: Balanced (auto)

Note

Copilot is running an experiment and ran this review at Balanced.

Comment thread skills/data/ol-dbt-migration-validation/SKILL.md
Comment thread skills/data/ol-dbt-migration-validation/SKILL.md Outdated
Comment thread skills/data/ol-dbt-migration-validation/SKILL.md Outdated
quazi-h and others added 6 commits September 16, 2026 11:12
…rior fixes

Seventh Copilot round. All four valid; three are regressions from the
previous two rounds' fixes, which is the more useful signal.

The dbt test command was a 52x own-goal. Round 5 told the reader to keep
the test selection identical to the build selection, but the build
deliberately includes `+<upstream>` ancestor trees -- so repeating it
selects every ancestor's own tests, and --indirect-selection=cautious
cannot exclude tests belonging to models you explicitly named. Measured:
`--select dim_course_run` runs 7 tests under cautious, `--select
"dim_course_run +dim_course_run"` runs 365. In the section headed "Test
only your own models". Now selects only the two comparison models, with
the reasoning corrected: the two selections are supposed to differ.

Step 3 still cd'd into src/ol_dbt, contradicting the repo-root contract
round 6 added at the top of the same procedure -- and the cd persists in
an interactive shell, so every later root-relative command resolved under
src/ol_dbt/src/ol_dbt. Now --project-dir like everything else.

`ol-dbt diff` cannot express a `where` clause, so round 6's instruction to
carry the uniqueness test's predicate into the keyed diff was
unfollowable: the only filter-adjacent flag is --limit, which caps printed
sample rows. On a conditionally proven key that is exactly the weak-key
fan-out documented two sections earlier. Step 5(d) now says to skip it
when the test has a `where`, and notes nothing is lost -- 5(b) needs no
key and step 6 is hand-written SQL, so both take the predicate directly.

Older text: the uniqueness grep scanned the whole schema file.
_marts__combined__models.yml declares 11 models and 14 uniqueness tests,
so it identified that a key exists, not which one was yours. Replaced with
an awk that prints only the selected model's block.

All four commands run verbatim from the repo root before committing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d one invocation style

Reviewed the whole diff the way the last seven rounds have, looking for the
patterns that keep recurring: a mandate with no implementation, a command
that depends on an unstated context, two styles in one file.

WRONG BASELINE. Step 2 took the `_pre` copy from `origin/main`, but main
moves while a PR is open -- measured on this branch: 3 commits touching 7
model files since divergence. So `origin/main:<model>.sql` can hand you
someone else's edit to the same model, and the diff then reports their
change as part of your migration. Now `git merge-base HEAD origin/main`.
Also `<layer>` rather than a hardcoded `marts/`, since reporting migrations
live under models/reporting/.

THE `where` MANDATE HAD NO IMPLEMENTATION. Last round I added "carry the
test's predicate into the grain check, the fill-rate comparison and the
diff" -- and Copilot caught that `ol-dbt diff` cannot express it. The same
gap was still open in the SQL: five blocks read <pre> and none had anywhere
to put a predicate. Step 5(a) and step 6 now take `<pred>` explicitly, with
one line telling you to carry it through the rest.

Step 6 also aliased a composite key wrong: `<key> as k` labels only the
last column. Harmless (EXCEPT compares full rows) but misleading, so the
template now lists the key columns bare with a note.

The registration gate hardcoded three layers while the section above it
says to register every source layer the selection reaches; it now says to
adjust the list.

ONE INVOCATION STYLE. ol-dbt-local-dev's prerequisite says to activate the
venv once, then used three styles below it: bare, `uv run --frozen`, and
bare again. All examples are bare now, matching the prerequisite; the only
remaining `uv run --frozen` is the prose describing it as the alternative.

Every changed snippet run verbatim: both SQL blocks parse with a predicate
applied, and the merge-base extraction returns the 337-line file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both from a real end-to-end run of this procedure against #2686 in another
session — the first time it has been exercised on a live migration rather
than reasoned about. Verified both here before writing.

THE +<upstream> INSTRUCTION IS UNFOLLOWABLE FOR THE COMMONEST TARGET.
`+dim_course_run` / `+dim_course` cannot build on dev_local:
dim_course_run.sql:191 calls `regexp_like` raw instead of through the
cross-db macro, and that is a Trino builtin DuckDB lacks (confirmed in the
file; tracked as tk-t1-unblock-local-validation-...-20b1b3). The #2686 run
measured ERROR=3 SKIP=5 with the model under test among the skipped,
leaving only the _pre side. New subsection: reconstruct the needed slice
from buildable ancestors, state that this verifies the derivation rather
than the built relation, and do NOT substitute a Glue view for the
unbuildable ancestor — that is the uncontrolled divergent-path case.

VERIFY FROM run_results.json, NOT THE LOG. A selected model can be skipped
while the command looks fine. Two mechanisms, both reproduced here:
`| tail` hides the per-model ERROR/SKIP lines behind dbt's deprecation
summary, and `| tail` also masks the exit status — bare run exits 1, piped
exits 0, piped under `set -o pipefail` exits 1. Noted that
${PIPESTATUS[0]} is a bash-ism and empty in zsh, where it is
${pipestatus[1]}.

One correction to the reported finding: dbt itself does exit 1 when a model
errors — I could not reproduce "dbt run exited 0". The 0 comes from the
pipe, so the fix is pipefail plus the artifact check rather than anything
about dbt's exit behaviour.

The artifact snippet is scoped to {error, fail, skipped, runtime error}
rather than != success, because test results use pass/warn and the naive
form reports every passing test as a failure. Verified both ways: none on a
real test-run artifact, and it catches error+skipped on a model run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd the stale-registration evidence

CORRECTION to d6806b0. I wrote that `+dim_course_run` fails because
dim_course_run.sql:191 calls `regexp_like` raw. That is false on current
main: PR #2658 dispatched it and merged 2026-09-11, and the dispatched form
is present in this branch's merge-base too. I read the raw version out of
the main checkout, which is 35 commits behind origin/main (153a76e vs
bb2bcfc) — I verified the claim against a stale tree, which is the same
class of mistake as testing a different command from the one I shipped.

The real blockers are three ANCESTORS carrying Trino-only JSON SQL, per
tk-three-trino-only-json-models-block-building-dim--59c2db and confirmed
against origin/main: stg__edxorg__api__course.sql:20 and
stg__mitxpro__app__postgres__cms_certificatepage.sql:17 (`json_query(...
with array wrapper)` wrapped in json_parse and cast to array), and
int__mitxpro__coursesfaculty.sql:9 (`cast(json_parse(...) as array (json))`).
The cascade is what makes it total: those error, int__edxorg__mitx_courseruns
and int__mitxpro__courses skip, and since the dims `union all` every platform
one broken branch skips the whole dim. Measured on #2686: PASS=48 ERROR=3
SKIP=5 TOTAL=56. Also noted #2658 explicitly so nobody hunts for the
regexp_like that is already gone.

Added the gate the #2686 run argued for, which is sharper than what I had:
both comparison sides must appear with status: success in run_results.json
before any number is measured -- not "the run finished", not "no errors
scrolled past".

Added the evidence for step 1's stability rule, which is the best argument
in the file for it: on #2686 a staging layer registered hours earlier read
4,513 rows where a fresh registration read 4,996, and that 483-row gap was
published and retracted. It was convincing because the stale side was
internally consistent — the _pre model and the built dim both came from it,
so the diff was a clean 0/0. A stale registration does not look stale, it
looks like agreement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd state the scope up front

Folds in what two manual validation runs on #2403 and #2686 learned but the
skill did not yet say.

CAUTIOUS DROPS THE PR'S OWN TEST. `--indirect-selection=cautious` includes a
test only when EVERY model it references is selected, so a new singular test
the PR itself adds — referencing the changed model plus something outside the
selection — is silently excluded. On #2403 that was the PR's own new test, the
one test most worth seeing. Switched the recommendation to `buildable`, which
relaxes exactly that condition and still excludes the eager relationships
noise. Re-measured both modes rather than trusting the July figures: on
dim_course_run eager 17 / buildable 8 / cautious 8, and the single test
buildable adds is the course_fk -> dim_course relationship.

RUN THE TESTS BEFORE THE DIFF, with the reason: a named failing test is more
diagnostic than a 20k-row mismatch report, and a broken model makes a MATCH
uninterpretable — you cannot distinguish agreement from two identically-wrong
sides.

WHAT TO EXPECT was missing entirely — the skill said which figures to trust
but never what the report looks like or what a pass is. Step 5(d) now says to
diff EVERY changed model with a per-model verdict written down first:
unaffected -> MATCH/exit 0; intentionally changed -> MISMATCH/exit 1 where the
SHAPE is the assertion (row delta 0, only the intended columns, the intended
direction). So a MISMATCH is not a failure and a MATCH is not automatically a
pass. Includes the #2403 numbers that turned an asserted claim into a measured
one (semester 12 -> 99 non-null over 87 re-versioned rows, body had said
"~100"), and the --exclude-columns effective_date,end_date that SCD2 models
need or the report is unreadable.

Also rewrote the frontmatter description and the opening paragraph, which
undersold the skill as "the acceptance procedure" when it is the whole
register/build/test/diff workflow. Anyone scanning the description could not
tell whether it covered the higher-level sequence or just the diff command.

Two stale spots caught by grepping for what the change touched: "test
separately, cautiously" now misread as the mode name, and a "runs 7 tests"
figure that re-measurement put at 8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps found by checking the skill against everything the #2403 and #2686
manual runs produced, rather than against the review findings.

NO NEGATIVE CONTROL. The skill said what a clean result looks like but never
how to prove the comparison can fail. A MATCH is unfalsifiable on its own —
it is indistinguishable from a diff that compared nothing. Added the recipe
(replace the column under test with a literal in _pre, rebuild that model
only, rerun) with the #2403 evidence that it does fail loudly: perturbing
semester gave `mismatch — 17888 unmatched row-side(s), 1 column value
mismatch(es)`, exit 1, rows listed. Also says to put the one-liner in the PR
body, since a reviewer cannot otherwise distinguish the two.

THE SHARED DUCKDB CAN STOP YOU, not just mislead you. The section covered
staleness and clobbering but not the hard failure: with another session
holding the file, ol-dbt diff dies on `_duckdb.IOException: Could not set
lock on file ... Conflicting lock is held` — 5 retries at ~20s on #2403.
Called out as contention rather than a defect in the change under review, so
nobody reports it as one.

Renumbered that list's intro from "Two consequences" to "Three", caught by
grepping what the edit touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Several documented checks can silently pass invalid comparisons or fail when executed as instructed.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (7)

skills/data/ol-dbt-migration-validation/SKILL.md:354

  • This checker does not implement the gate described below. It prints NOT OK: none when the selector matched no comparison models, and it always exits 0 even when bad is non-empty. Explicitly require both expected model names to have success status and exit nonzero otherwise.
import json; d=json.load(open('src/ol_dbt/target/run_results.json'))
BAD={'error','fail','skipped','runtime error'}
bad=[(r['status'], r['unique_id'].split('.')[-1]) for r in d['results'] if r['status'] in BAD]
print('NOT OK:', bad or 'none')
"

skills/data/ol-dbt-migration-validation/SKILL.md:556

  • The key-discovery pipeline cannot find a single-column key: its grep only emits compound-test syntax, while the following rule requires locating unique plus not_null. For example, models/dimensional/_dim_date.yml:11-15 declares date_key using those two generic tests, and this command produces no key evidence for it. Include column names and the generic test names in the scoped output.
awk -v m="<model>" '$0=="- name: "m {f=1; print; next} f && /^- name: / {exit} f' \
  "src/ol_dbt/$yml" \
  | grep -nE 'unique_combination_of_columns|expect_compound_columns_to_be_unique|combination_of_columns|column_list|where:'

skills/data/ol-dbt-migration-validation/SKILL.md:665

  • This query omits the <pred> that lines 649-650 require. For a conditionally unique model such as dim_course_run, it therefore compares historical rows too, so the reported per-column differences do not describe the same population as the grain and mapping checks.
select count(*) from (select "<col>" from <pre> except all select "<col>" from <new>);
select count(*) from (select "<col>" from <new> except all select "<col>" from <pre>);
**skills/data/ol-dbt-migration-validation/SKILL.md:692**
* The fill-rate example also drops the required `<pred>`. This makes its denominator include rows outside the population where the selected key is proven, contradicting lines 582-591 and potentially hiding a current-row fill-rate regression among SCD2 history.

select 'pre' as side, count() as n_rows,
count("") as non_null, round(100.0
count("")/count(),2) as pct
from


union all select 'new', count(
), count(""), round(100.0count("")/count(),2) from ;

**skills/data/ol-dbt-migration-validation/SKILL.md:742**
* These two checks do not cover what a row-level diff reports. Per-column multisets lose key-to-value association, and step 6 checks only `<changed_col>`; for example, swapping an untouched value between two keys leaves every multiset, row count, and changed-column mapping unchanged. Add a filtered full-row `EXCEPT ALL` comparison (excluding nondeterministic columns), or filtered wrapper models, before claiming the keyed diff can be skipped without loss.

You lose nothing by skipping it. Step 5(b)'s multiset diff needs no key at all, and
step 6's mapping assertion is hand-written SQL — both take a where directly, so
together they cover what 5(d) would have told you, on a key you can actually
defend. Reach for ol-dbt diff when the key is proven across the whole relation.

**skills/data/ol-dbt-migration-validation/SKILL.md:751**
* The procedure mandates repository-root execution, but this bare `dbt run` has neither `--project-dir` nor `DBT_PROFILES_DIR`; from the root it fails because there is no root `dbt_project.yml`. Anchor the negative-control command like the earlier dbt commands.

uv run --frozen dbt run -t dev_local --full-refresh --select "_pre"

**skills/data/ol-dbt-migration-validation/SKILL.md:251**
* Calling a snapshot an alternative conflicts with this procedure's same-registration requirement. If the current model was built under an older registration and the edited model follows step 1 by re-registering, the frozen baseline and new build use different inputs, so the diff can attribute source drift to code. Specify the fresh-register/build/snapshot/no-reregister sequence, or restrict this alternative to non-acceptance iteration.

ol-dbt local snapshot <model> --as <model>_baseline is the alternative when
the old code is not in git (an uncommitted edit) — it freezes the current build
as a plain table, immune to pointer rot. Then diff with --old-raw. Prefer the
_pre model file when the old code is a commit away, which for a migration PR
it always is.


- **Files reviewed:** 3/3 changed files
- **Comments generated:** 4
- **Review effort level:** Balanced (auto)
</details>

> [!NOTE]
> Copilot is running an experiment and ran this review at Balanced.

Comment thread agent-config.toml
[profiles.migration]
skills = [
"ol-dbt-local-dev",
"ol-dbt-fast-validation",
Comment on lines +149 to +152
grep -q '✗ Errors: 0' "/tmp/reg_$db.log" || {
echo "STOP: $db registration had errors — do not build" >&2
exit 1
}
Comment on lines +117 to +121
**Read the `✗ Errors:` line — a non-zero count does not fail the command.**
`register` catches per-table failures, prints the tally, and still exits 0. A table
that fails to re-register keeps its previous view, so a run that *looks* successful
can leave you on exactly the stale pointer you were trying to replace. Treat
`✗ Errors: 0` as the success condition, not the exit status.
Comment on lines 174 to +181
ol-dbt local snapshot my_model --as my_model_baseline # materialize a frozen copy
# ...edit the SQL...
ol-dbt run --select my_model
ol-dbt run --select my_model --full-refresh
ol-dbt diff --old my_model_baseline --old-raw --new my_model --primary-key my_model_pk
```
The snapshot is frozen, but the rebuild is not: without `--full-refresh` an
incremental `my_model` may leave the edited rows untouched, and the diff then
reports "no change" for a change that simply never ran.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants