Skip to content

#450 — cross-model column trace-to-source + blast-radius (normalized join key) - #481

Merged
cmbays merged 10 commits into
mainfrom
adapters-450-cross-model-trace
Jun 23, 2026
Merged

cmbays merged 10 commits into
mainfrom
adapters-450-cross-model-trace

Conversation

@cmbays

@cmbays cmbays commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

CLL-4 — the v0.2 explorer founder headline: trace a column to its SOURCE field across ref() boundaries, plus column-grain downstream blast-radius. Runs the CLL-2/CLL-3 intra resolver over every model to build the project-wide output-column map (cute-dbt's zero-compute catalog-equivalent), then stitches at ref() boundaries. The ColumnScope::Cross arm reserved by CLL-2 becomes live.

Closes #450.

Normalized join key (refinement 3 — the load-bearing correctness seam)

The cross-model ref()↔leaf join uses a normalized (database, schema, identifier) tuple built AT INGESTION (RelationIndex::from_manifest), case-folded — NOT a raw relation_name string-match (engine leaf refs are bare lowercased leaves; aliasing / identifier config / case-folding make a raw string-match a hazard).

Never a false claim: the stitch attributes a leaf to an upstream NodeId ONLY when it is BOTH (a) a real depends_on producer (from DagFacts.lineage/ModelLineage.backward) AND (b) the unique producer whose normalized identifier matches the leaf. Non-unique / no-match → Opaque — never a wrong upstream. Verified on the real 35-model playground manifest: 0 edges attribute to a non-producer.

What's built

  • C — trace-to-source: forward recursion across ref() boundaries to the source()/seed leaf; terminates Source/Root/Opaque (honest thinning).
  • B — blast-radius: BFS with a visited-set over the cross-model edge set (the child_map direction) — reads ModelLineage, never a third depends_on self-inversion.
  • Star discipline: select * over a KNOWN modeled upstream → Resolved via the derived projection map; * over an UNKNOWN external → Opaque; chains of * compound Opaque (no catalog, no fabrication).
  • Scope-as-parameter (architectural): the project-wide graph is built ONLY in the explorer arm (build_cross_model_columns in render_explore). The per-model report path is untouched — report goldens byte-identical; only the two playground-based explore dag.html goldens change (verified by diffing a fresh report render against the committed golden).

Tests (TDD-first, the real parse path)

  • 15 domain unit tests in src/domain/column_lineage.rs (pure POD + std/serde — passes domain_clean_arch).
  • 10 integration tests in tests/cross_model_column_lineage.rs built through the REAL multi-model parse path (parse_cte_graph → model_outputs → ProjectColumnGraph), never a hand-authored join: normalized-join-key never-mis-join, cross-model stitch, B blast-radius, C trace-to-source, star-over-known→Resolved / star-over-unknown→Opaque / star-chain→compounding-Opaque, scope-as-parameter, uses DagFacts.lineage.

Dogfood

The existing synthetic playground manifest already exercises a cross-model trace + star-over-ref — 975 cross-model edges, 144 source-terminal traces (dim_organizations → stg_synthea__organizations → the organizations source). No new fixture, no root_path leak.

Gates (all raw-exit-0 locally)

fmt --check · clippy --all-targets --locked -D warnings · full nextest (2621 pass) · BDD (242 scenarios) · cargo doc -D warnings · cargo deny · domain_clean_arch · lineage_seam · resource_ref_lint · crap4rs (PASS — column_lineage.rs added to the strict ≤15 keyed set) · explore goldens regen + byte-verified · report goldens byte-identical.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added cross-model, column-level lineage to the explore interface, including traced upstream attribution and downstream blast-radius impact across connected models.
    • Implements deterministic project-wide stitching using normalized relation identity, with rename and pass-through rules, and clear opacity behavior for non-enumerable or unknown select * scenarios.
  • Tests

    • Added integration coverage validating edge stitching, source attribution rules, star-discipline/opacity compounding, narrowed-column “never false claims,” and downstream reachability propagation.

…ius (normalized join key)

CLL-4 — the v0.2 explorer headline: trace a column to its SOURCE field across
ref() boundaries, plus column-grain downstream blast-radius. The project-wide
output-column map (cute-dbt's zero-compute catalog-equivalent) is built by
running the CLL-2/CLL-3 intra resolver over EVERY model and stitching at ref()
boundaries.

Normalized join key (refinement 3 — the hardest correctness seam): a
(database, schema, identifier) tuple parsed from relation_name AT INGESTION
(RelationIndex), case-folded — NOT a raw relation_name string-match. The
cross-model stitch is gated on BOTH the normalized identifier AND the model's
ACTUAL depends_on producers (DagFacts.lineage / ModelLineage.backward), so a
leaf that does not uniquely normalize-join against a real producer degrades to
Opaque — NEVER attributed to a wrong upstream. Verified on the real 35-model
playground manifest: 0 edges attribute to a non-producer.

- C (trace-to-source): forward recursion across ref() boundaries to the
  source()/seed leaf; terminates Source/Root/Opaque (honest thinning).
- B (blast-radius): BFS with a visited-set over the cross-model edge set
  (the child_map direction) — reads ModelLineage, never a third self-inversion.
- Star discipline: select * over a KNOWN modeled upstream resolves via the
  derived projection map; * over an UNKNOWN external stays Opaque; chains of *
  compound Opaque (no catalog, no fabrication).

Scope-as-parameter (architectural): the project-wide graph is built ONLY in
the explorer arm (build_cross_model_columns in render_explore). The per-model
report path is untouched — the report goldens are byte-identical; only the
playground-based explore goldens change. Verified by diffing a fresh report
render against the committed golden.

New domain module src/domain/column_lineage.rs (pure POD + std/serde — passes
domain_clean_arch); the ColumnScope::Cross arm reserved by CLL-2 becomes live.
Dogfood: the existing synthetic playground manifest already exercises a
cross-model trace + star-over-ref (975 edges, 144 source-terminal traces) — no
new fixture needed, no root_path leak.

TDD: 15 domain unit tests + 10 integration tests built through the REAL
multi-model parse path (parse_cte_graph then model_outputs then
ProjectColumnGraph), never a hand-authored join. crap4rs: column_lineage.rs
added to the strict 15 keyed set.

Closes #450

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199AmBCec5kyVEEF1Qd7TSF
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@cmbays, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 11 minutes and 42 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses rolling per-developer review limits. Reviews become available again as older review attempts age out of the rolling limit window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 802a0788-75ae-4d84-b9ac-660f0dc266f5

📥 Commits

Reviewing files that changed from the base of the PR and between 131adde and d5a5c84.

📒 Files selected for processing (7)
  • examples/explore-macro/dag.html
  • examples/explore/dag.html
  • src/adapters/cte_engine.rs
  • src/domain/column_lineage.rs
  • src/domain/cte.rs
  • src/domain/mod.rs
  • tests/cross_model_column_lineage.rs
📝 Walkthrough

Walkthrough

Implements project-wide cross-model column lineage (CLL-4) by adding a new domain/column_lineage.rs module with NormalizedRelation, RelationIndex, ProjectColumnGraph, and trace-to-source plus blast-radius traversals. Bridges intra-model CTE facts via CteGraph::model_outputs, surfaces results through the explorer adapter as CrossModelColumnsPayload, and validates all correctness seams with an integration test suite.

Changes

Cross-model column lineage (CLL-4)

Layer / File(s) Summary
Relation identity, index, and model output contracts
src/domain/column_lineage.rs, src/domain/mod.rs
NormalizedRelation and parse_relation_name implement dbt-style 3-segment relation parsing (dot-split with double-quote handling) and ASCII case-folding. RelationIndex builds manifest-ingestion-time normalized (database, schema, identifier) keys to NodeId mappings with ambiguity tracking and globally-unique bare-leaf resolution. ModelOutputs carries per-model terminal output columns (enumerable or None for opaque-star), leaf references, and source-passthrough columns. Domain module and re-exports registered in mod.rs.
CteGraph::model_outputs — intra-to-cross-model bridge
src/domain/cte.rs
New public method derives cross-model facts from already-computed intra-model column_edges: detects terminal opaque-star edges to set output_columns = None, de-duplicates terminal landing columns in first-seen order, recovers leaf refs for WITH-less models by scanning column_edges intra-scopes, identifies leaf-reading nodes, and computes source-passthrough columns via memoized depth-capped reachability walk filtering pure pass-through chains. Returns ModelOutputs::with_passthrough for ProjectColumnGraph::build.
ProjectColumnGraph: build, stitch pipeline, trace, and blast
src/domain/column_lineage.rs
Defines CrossModelEdge, StitchOutcome, ProjectColumnGraph, TraceHop, TraceTermination, TraceToSource. Deterministic build method runs two stitching phases: (1) stitch_enumerable for enumerable upstream flows constrained by ModelLineage::backward producer sets with downstream projection narrowing, (2) source_name_carry for single-source name propagation only when column is in source_passthrough subset, plus stitch_leaf as never-mis-join seam via RelationIndex normalized-key resolution. trace_to_source walks backward with cycle guarding, classifying termination as Source, Opaque, or Root. blast_radius performs BFS downstream column reachability.
Explorer adapter: payload types and build_cross_model_columns
src/adapters/explore.rs
Adds CrossModelColumnsPayload and CrossModelEdgePayload carrier structs (with via_star flag to mark star-resolved edges) plus optional serde-skipped cross_model_columns field on LineagePayload. build_cross_model_columns constructs full project graph by building RelationIndex and ModelLineage from manifest, running parse_cte_graph + model_outputs over every compiled model node, delegating to ProjectColumnGraph::build, and returning None when no edges exist. render_explore wires result; lineage_payload_from initializer sets field to None for byte stability on non-explorer paths.
Integration tests and complexity gate
tests/cross_model_column_lineage.rs, crap4rs.toml
Comprehensive integration test suite asserting: normalized join-key correctness and anti-mis-join (never attributing edges to same-leaf non-producers), stitch edge generation across renamed columns, trace-to-source crossing ref boundaries to correct source leaf, computed columns never mis-attributed, blast-radius downstream reachability, star discipline (known modeled upstreams resolve via via_star, unknown external relations degrade to Opaque with no edges), opacity compounding across star chains, scope-as-parameter boundary (report stays intra-only), DAGFacts lineage authority gating (no cross-model edges for read-by-name without declared producers), narrowing regression (no phantom edges for dropped columns, trace/blast exclude narrowed-away impact), rename semantics, mixed-case column normalization, and CTE import/join narrowing. Configuration threshold override for src/domain/column_lineage.rs set to 15 in crap4rs.toml.

Sequence Diagram

sequenceDiagram
  participant Explorer as render_explore
  participant BuildCML as build_cross_model_columns
  participant RelIdx as RelationIndex
  participant CTE as parse_cte_graph
  participant CteGraph
  participant ProjGraph as ProjectColumnGraph
  participant Trace as trace_to_source
  participant Blast as blast_radius

  Explorer->>BuildCML: Manifest
  BuildCML->>RelIdx: from_manifest
  RelIdx-->>BuildCML: normalized keys ↔ NodeId
  
  loop per model
    BuildCML->>CTE: compiled SQL
    CTE-->>CteGraph: intra-model edges
    CteGraph->>CteGraph: model_outputs(terminal)
    CteGraph-->>BuildCML: output_columns, leaf_refs, passthrough
  end

  BuildCML->>ProjGraph: build(manifest, lineage, index, model_outputs)
  ProjGraph->>ProjGraph: stitch_enumerable (producer-constrained)
  ProjGraph->>ProjGraph: source_name_carry (passthrough-only)
  ProjGraph->>ProjGraph: stitch_leaf (never-mis-join via RelIdx)
  ProjGraph-->>BuildCML: ProjectColumnGraph {edges, outputs}
  
  BuildCML-->>Explorer: CrossModelColumnsPayload or None
  Explorer->>Trace: trace column to source
  Trace-->>Explorer: TraceToSource {hops, termination}
  
  Explorer->>Blast: downstream impact
  Blast-->>Explorer: Vec~TraceHop~
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~100 minutes

Possibly related PRs

  • breezy-bays-labs/cute-dbt#218: Defines the original LineagePayload structure in src/adapters/explore.rs that this PR extends with the cross_model_columns field.
  • breezy-bays-labs/cute-dbt#460: Introduces CteGraph::column_edges and intra-model terminal output facts that CteGraph::model_outputs and build_cross_model_columns consume to derive cross-model inputs.

Poem

🐇 Hoppity-hop through the schema maze,
Each column traced through dbt's winding ways,
The star resolves when the upstream's known,
And opaque stays honest when the source has flown.
From source to mart, every ref stitched tight—
No mis-join seam shall mis-attribute right! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main work: cross-model column lineage with trace-to-source and blast-radius features, and mentions the normalized join key as the critical implementation detail.
Linked Issues check ✅ Passed The PR addresses all seven acceptance criteria from issue #450: projection resolver runs over every model, uses normalized (database, schema, identifier) tuple as join key, leverages DagFacts lineage/child_map, implements both blast-radius and trace-to-source, handles star discipline correctly, maintains scope separation between report and explorer arms, and regenerates explore goldens.
Out of Scope Changes check ✅ Passed Configuration override for strict gating in crap4rs.toml and comprehensive test coverage align with domain objectives; all changes directly support cross-model column lineage implementation per issue #450 scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch adapters-450-cross-model-trace

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

📄 Rendered report preview

All golden examples regenerated cleanly.

🟡 Golden examples

Committed to examples/ and byte-identity gated — the canonical reports contributors and consumers browse. Stable across PRs.

Report View Download
seed-showcase-report.html ▶ Open ↗ ⬇ Download
jaffle-shop-report.html ▶ Open ↗ ⬇ Download
playground-report.html ▶ Open ↗ ⬇ Download
diff-showcase-report.html ▶ Open ↗ ⬇ Download
prdiff-minidag-report.html ▶ Open ↗ ⬇ Download
macro-heavy-report.html ▶ Open ↗ ⬇ Download

🐶 Live dogfood preview

This PR doesn't touch dbt-project/, so there's no live dogfood preview.

🧭 Explore preview

The two-page cute-dbt explore explorer — dag.html (model lineage) + tests.html (unit-test viewer). Same golden/live split as the report.

🟡 Golden explore

The committed examples/explore/ playground golden (the full synthetic playground manifest). Byte-identity gated in Example report check. Stable across PRs.

Page View Download
explore/dag.html ▶ Open ↗ ⬇ Download
explore/tests.html ▶ Open ↗ ⬇ Download

🐶 Live explore

This PR doesn't touch dbt-project/, so there's no live explore preview.

▶ Open ↗ opens the report or explorer in your browser in one
click — published to this repo's GitHub Pages under
/pr-481/.
⬇ Download fetches the same self-contained HTML as a workflow
artifact (auth-gated; works fully offline). Either way the report
makes zero external resource requests.

The Pages preview may take ~1 min to update after this comment
posts. On PRs from forks the Open link is unavailable (read-only
token) — use Download.

Alternative: GitHub CLI
# gh CLI >= 2.63 extracts into ./report-preview-playground/.
gh run download 28047574558 -R breezy-bays-labs/cute-dbt -n report-preview-playground
open report-preview-playground/playground-report.html

Posted by report-preview.yml for d5a5c8457e95d59ad016d2191fb81a177b56ada0. Affordance only — never blocks merge.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request implements project-wide cross-model column lineage (CLL-4) to enable tracing columns back to their source fields and calculating downstream blast-radius. It introduces a new domain module column_lineage.rs for stitching model outputs at ref() boundaries using a normalized relation index, updates the explorer adapter to build and serialize this graph, and adds comprehensive integration tests. The review feedback highlights two optimization opportunities: utilizing the by_identifier index in stitch_leaf to avoid O(M) complexity over the entire relation map, and performing a direct lookup in node_for_bare_leaf to prevent redundant string allocations on already-lowercased identifiers.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/domain/column_lineage.rs
Comment thread src/domain/column_lineage.rs
…urce name-carry honesty)

The cross-model source name-carry (a non-enumerable source's column origin)
was over-claiming: a column COMPUTED in-model (current_timestamp as
_loaded_at, a surrogate row_number() key) was attributed to the upstream
source — a fabricated lineage claim (the column does not ORIGINATE there).
On the real playground manifest this produced 9 false _loaded_at -> source
edges.

Fix: the name-carry now only covers a downstream output column the SQL PROVES
flows UNCHANGED to a leaf-reading boundary — a pure pass-through/rename chain
(CteGraph::model_outputs now carries source_passthrough_columns, computed by
column_reaches_leaf tracing each terminal column's intra chain to an EXTERNAL
leaf, never a Derived/computed dead-end and never a sibling-CTE ref). A
computed column dead-ends at its Derived expression and is excluded; its trace
terminates honestly (not Source). Real playground: 0 _loaded_at -> source
edges; genuine pass-through columns still trace.

Added a TDD test (computed_column_is_never_attributed_to_a_source) over the
real parse path. Decomposed model_outputs into terminal_output_columns /
model_leaf_refs / leaf_reading_nodes helpers to keep CRAP under threshold
(was 31, now a thin dispatcher). Explore goldens regenerated + byte-verified;
report goldens byte-identical (scope separation intact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199AmBCec5kyVEEF1Qd7TSF

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/domain/column_lineage.rs`:
- Around line 1143-1149: The graph_serde_round_trips test function only provides
round-trip coverage for ProjectColumnGraph and does not test the new wire types
that implement Serialize and Deserialize boundaries. Convert the existing test
or create additional property-based tests using a property testing framework to
cover NormalizedRelation, RelationIndex, ModelOutputs, StitchOutcome, and
TraceToSource. Each type should have property-based coverage that generates
random instances, serializes them to JSON, deserializes them back, and asserts
equality, following the coding guidelines that require property tests for JSON
serde round-trip coverage on these types.
- Around line 286-294: The ModelOutputs::new constructor stores output_columns
and leaf_refs without normalizing them to lowercase, but the documented behavior
promises lowercased values which are expected by trace_to_source and
blast_radius during matching. Modify the constructor to normalize both
parameters: for output_columns, map over the Option and lowercase each string in
the Vec, and for leaf_refs, lowercase each string in the Vec before storing them
in the struct.
- Around line 485-488: The current implementation uses `.find()` in the edges
iterator to locate the next upstream column, which only returns the first
matching edge. This is problematic because a downstream column can have multiple
incoming cross-model edges (from duplicate star columns or derived columns with
multiple inputs), and picking the first one makes the trace sort-order dependent
and potentially incorrect. Instead of using `.find()`, check if there are
multiple edges matching the criteria (where e.downstream equals current.0 and
e.downstream_column equals current.1), and if multiple matches exist, treat this
as an ambiguous case and handle it appropriately by either returning an error or
None since the API does not yet support branching traces.

In `@src/domain/cte.rs`:
- Around line 854-905: The model_outputs function currently returns only
flattened lists of output_columns and leaf_refs, losing the relationship between
leaves and their corresponding output columns, which prevents downstream from
distinguishing between different lineage scenarios. Refactor the function to
return terminal-reaching edge mappings that preserve the association between
each leaf, the upstream column, downstream column, and the edge type (star or
opaque), then derive leaf_refs from these mappings. This requires changing the
ModelOutputs return type to include these mapping details instead of just
separate lists of output_columns and leaf_refs.

In `@tests/cross_model_column_lineage.rs`:
- Around line 285-309: The test function
star_over_unknown_external_stays_opaque_no_fabrication has a mismatch between
its name/comments (which describe Opaque behavior) and its assertion (which
expects TraceTermination::Root). Update the assertion on the trace.termination
field to expect TraceTermination::Opaque instead of TraceTermination::Root to
align the actual test assertion with the documented contract described in the
test name and comments.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ec118c03-5d9a-4c82-994c-bd0cce1913b2

📥 Commits

Reviewing files that changed from the base of the PR and between 68e2842 and 3e0d265.

📒 Files selected for processing (8)
  • crap4rs.toml
  • examples/explore-macro/dag.html
  • examples/explore/dag.html
  • src/adapters/explore.rs
  • src/domain/column_lineage.rs
  • src/domain/cte.rs
  • src/domain/mod.rs
  • tests/cross_model_column_lineage.rs

Comment thread src/domain/column_lineage.rs
Comment thread src/domain/column_lineage.rs Outdated
Comment thread src/domain/column_lineage.rs
Comment thread src/domain/cte.rs Outdated
Comment thread tests/cross_model_column_lineage.rs
…aque contract (CodeRabbit)

Resolve the bot-flagged honesty issues on the cross-model column trace
(floor: never-a-false-claim — degrade over a wrong attribution).

- trace_to_source: a downstream column with MORE THAN ONE incoming
  cross-model edge (duplicate `select *` columns, a derived column with
  several inputs) no longer picks the first via `.find()` (sort-order-
  dependent WRONG source). It now `.filter()`s and degrades to Opaque on a
  fork — the single-chain API cannot represent a branch. (CodeRabbit Major
  ~488). New lib test multi_upstream_column_traces_opaque_not_first_wins
  (pre-fix: picks up_a, Root, 2 hops → fails).

- column_thins_opaque: a model whose OWN terminal projection is
  non-enumerable (`*` over an unknown external, outputs[node] == None) now
  thins to Opaque even with no manifest producer — the documented
  unknown-external contract. The CODE previously yielded Root (a false
  "originates here" claim); fixed the code, then the test asserts Opaque.
  (CodeRabbit Major ~309).

- ModelOutputs::new: normalize output_columns/leaf_refs to lowercase at the
  boundary (docs promise lowercased; trace/blast_radius lowercase query
  columns). (CodeRabbit ~294).

- node_for_bare_leaf: direct lookup first, lowercase only on miss — no
  happy-path allocation. (gemini ~254).

- stitch_leaf fallback: use the by_identifier index (O(log M)) instead of
  scanning the whole by_relation map (O(M)); behavior-preserving. (gemini
  HIGH ~729).

- new_wire_types_serde_round_trip: exhaustive JSON round-trip over the new
  wire types (NormalizedRelation, ModelOutputs, StitchOutcome,
  TraceToSource × every TraceTermination). RelationIndex is documented NOT
  a JSON wire type (struct-keyed map; in-process index only). (CodeRabbit
  ~1149).

Scope discipline: per-model *-report.html goldens byte-identical; explore
goldens regenerate identically (the report path never builds the project
graph).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199AmBCec5kyVEEF1Qd7TSF

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/domain/column_lineage.rs (1)

604-615: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Make opaque thinning column-grained.

Line 614 checks whether any column edge lands on the node. A computed-only column on a model with a producer returns Opaque when no other column resolved, but the same computed column returns Root once an unrelated pass-through column adds an inbound edge. Carry opaque-thin facts keyed by (node, column) from build, or pass the queried column into this check so trace termination does not depend on unrelated columns.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/column_lineage.rs` around lines 604 - 615, The column_thins_opaque
method currently checks if any column edge lands on a node at line 614, making
the result node-grained rather than column-grained. This causes inconsistent
behavior where a computed-only column returns different results depending on
whether unrelated columns add edges to the same node. To fix this, either
refactor the method to accept the specific column being queried and check only
for edges matching that column, or track opaque-thin facts with (node, column)
tuple keys during the build phase and look up the result using both the node and
column identifiers instead of only the node. This ensures trace termination
depends only on the relevant column's edges, not unrelated columns on the same
node.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/domain/column_lineage.rs`:
- Around line 338-346: The with_passthrough constructor in column_lineage.rs is
not normalizing its inputs to lowercase, which causes the output_columns,
leaf_refs, and source_passthrough_columns to bypass the lowercase boundary
enforcement that the new constructor applies. This leads to mixed-case names
missing lookups in trace_to_source and blast_radius. Apply the same lowercase
normalization logic to all three input parameters (output_columns, leaf_refs,
and source_passthrough_columns) in the with_passthrough constructor that is
currently being applied in the new constructor to ensure consistent behavior
across both constructors.

In `@src/domain/cte.rs`:
- Around line 1033-1047: The condition in the matches! macro that filters
ColumnEdgeKind is allowing both PassThrough and Renamed edges, but this can
cause source_name_carry to fabricate column traces for renamed columns without
verifying the original source column exists. Remove ColumnEdgeKind::Renamed from
the matches! condition so that only ColumnEdgeKind::PassThrough is permitted,
preventing false lineage traces for renamed columns like turning select amount
as order_amount into a fabricated source.order_amount mapping.

---

Outside diff comments:
In `@src/domain/column_lineage.rs`:
- Around line 604-615: The column_thins_opaque method currently checks if any
column edge lands on a node at line 614, making the result node-grained rather
than column-grained. This causes inconsistent behavior where a computed-only
column returns different results depending on whether unrelated columns add
edges to the same node. To fix this, either refactor the method to accept the
specific column being queried and check only for edges matching that column, or
track opaque-thin facts with (node, column) tuple keys during the build phase
and look up the result using both the node and column identifiers instead of
only the node. This ensures trace termination depends only on the relevant
column's edges, not unrelated columns on the same node.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f6f435d2-0de0-41f6-a18c-818454661af8

📥 Commits

Reviewing files that changed from the base of the PR and between 3e0d265 and 4077160.

📒 Files selected for processing (5)
  • examples/explore-macro/dag.html
  • examples/explore/dag.html
  • src/domain/column_lineage.rs
  • src/domain/cte.rs
  • tests/cross_model_column_lineage.rs

Comment thread src/domain/column_lineage.rs
Comment thread src/domain/cte.rs Outdated
…tputs (no phantom narrowed-away columns)

stitch_enumerable flowed the upstream's FULL output column set into
downstream cross-model edges without checking whether the downstream
actually exposes each column. When a downstream NARROWS its projection
(the most common dbt pattern), every dropped column became a phantom
cross-model edge — breaking never-a-false-claim:
blast_radius(stg,"amount") falsely listed a downstream that dropped
amount, and trace_to_source returned a confident chain for a column the
downstream never exposes.

Fix: intersect each flowed upstream column against the downstream
model's own terminal output_columns; emit a cross-model edge only for a
column the downstream actually exposes. A non-enumerable downstream
terminal (None) cannot bound the set, so the prior pass-through stands.
Sound seams preserved: star-over-known→Resolved for exposed columns,
unknown-external→Opaque, multi-upstream→Opaque fork, scope separation.

Tests through the REAL parse_cte_graph + ProjectColumnGraph::build path
(never hand-authored edges): the verifier's stg(order_id,amount)→
dim(select order_id) repro asserts no stg.amount→dim.amount edge,
blast_radius omits dim, trace_to_source fabricates no chain; a
CTE-import-join narrowing repro; and a regression guard that the
exposed column keeps its sound edge + traces to source.

Explore goldens regenerated: 943→363 cross-model edges per page (new set
a strict subset of old — 580 phantoms removed, 0 added). Per-model
*-report.html goldens byte-identical (cross-model is explore-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199AmBCec5kyVEEF1Qd7TSF
@cmbays

cmbays commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

⏸️ Held for founder review — not merging (honest assessment)

CLL-4 is built, CI-green, and architecturally sound — the project graph stays in the explorer arm (per-model *-report.html goldens are byte-identical), the new src/domain/column_lineage.rs is clean, and several real bugs were caught + fixed (the phantom-narrowed-column over-claim removed 580 fabricated edges on the real healthcare_analytics explore golden — a strict subset, 0 added).

But I'm holding it rather than merging. Across four review rounds this slice has surfaced six honesty findings, and it is not converging — each fix-round reveals new Majors, and the dedicated adversarial verifier (which returned sound) missed the one below that CodeRabbit then caught:

# Finding Status
905 terminal edge mappings collapsed → fabricable edges self-fixed (9163bb3)
488 .find() picks first source on multi-upstream fixed (4077160) → Opaque
309 test pinned Root not Opaque for unknown-external * (code was wrong) fixed (4077160)
phantom upstream's full column set flowed without intersecting downstream outputs → phantom edge on every narrowing projection (most common dbt pattern) fixed (f0c912e)
1047 source_name_carry allows Renamed → select amount as order_amount from source fabricates source.order_amount → model.order_amount (a source column that doesn't exist) OPEN — genuine false claim
346 with_passthrough bypasses the lowercase boundary → mixed-case columns silently miss trace_to_source/blast_radius OPEN (honest silent-drop)

Why hold, not fix-and-merge: the cross-model column logic is intricate enough (1200+ LOC, the project-wide stitch) that it keeps yielding genuine fabrications on each pass, and my verification layers aren't fully catching them. This is the hardest, headline slice — it deserves a fresh, careful pass (your eyes on the column_lineage.rs design) rather than another rushed fix-round at the tail of a long autonomous run. The never-a-false-claim floor says: don't ship 1047, and don't merge a slice that isn't converging.

Nothing is lost — all work is on adapters-450-cross-model-trace (HEAD f0c912e). Recommended next step: fix 1047 (conservatively exclude Renamed from source-name-carry until a terminal→leaf column mapping exists) + 346 (normalize with_passthrough like new), then a fresh full adversarial pass specifically on source-name-carry and the rename path. CLL-4 is explorer-scope and separable — the merged spine (S1–S3) + intra-model CLL (CLL-2/CLL-3) are unaffected, so the report harness + Claude Design handoff do not depend on this.

@cmbays cmbays added the priority:soon Important — schedule in the current or next cycle label Jun 23, 2026
…malize with_passthrough (CodeRabbit)

source_name_carry no longer fabricates a same-name source column for a
RENAMED output (e.g. `CODE as condition_code` no longer claims
source.condition_code — the source field is CODE). column_reaches_leaf
now requires a pure PassThrough chain (Renamed excluded) so only a
same-name column may name-carry to its source; a renamed column degrades
to no source edge until a terminal→leaf original-column mapping exists.
A genuine pass-through still traces correctly.

with_passthrough now lowercases output_columns/leaf_refs/passthrough
names exactly like new(), so a mixed-case output column (e.g. OrderId)
is not silently missed by the lowercased trace_to_source/blast_radius
lookups on the real explorer path.

Explore goldens regenerated: 68 fabricated rename edges REMOVED from each
dag.html (0 added); report goldens byte-identical (report path never
builds the project graph).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199AmBCec5kyVEEF1Qd7TSF

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
tests/cross_model_column_lineage.rs (1)

861-866: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Tighten this assertion to avoid silently passing on non-enumerable outputs.

if let Some(cols) makes the key expectation optional; if dim_outputs regresses to None, this test still passes and can hide a real projection/output regression. Assert Some(...) (or at least is_some()) here so the test fails when output enumeration unexpectedly disappears.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cross_model_column_lineage.rs` around lines 861 - 866, The conditional
check using `if let Some(cols) = &dim_outputs` makes the assertion optional,
allowing the test to pass silently if `dim_outputs` is `None`, which can hide
real regressions in output enumeration. Replace this with an explicit assertion
that `dim_outputs` is `Some(...)` using either `unwrap()`, `expect()`, or
`assert!(is_some())` to ensure the test fails when output enumeration
unexpectedly disappears, rather than passing silently when `dim_outputs` is
`None`.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/cross_model_column_lineage.rs`:
- Around line 861-866: The conditional check using `if let Some(cols) =
&dim_outputs` makes the assertion optional, allowing the test to pass silently
if `dim_outputs` is `None`, which can hide real regressions in output
enumeration. Replace this with an explicit assertion that `dim_outputs` is
`Some(...)` using either `unwrap()`, `expect()`, or `assert!(is_some())` to
ensure the test fails when output enumeration unexpectedly disappears, rather
than passing silently when `dim_outputs` is `None`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3ab2719f-ae1a-46f5-948d-0cbff4c341a4

📥 Commits

Reviewing files that changed from the base of the PR and between 4077160 and 131adde.

📒 Files selected for processing (5)
  • examples/explore-macro/dag.html
  • examples/explore/dag.html
  • src/domain/column_lineage.rs
  • src/domain/cte.rs
  • tests/cross_model_column_lineage.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/domain/cte.rs

cmbays and others added 5 commits June 23, 2026 11:36
…name-tracking (no fabricated source column)

Round-4 closes the last open fabrication shape (the 1047 class): a column
RENAMED in a leaf-reading CTE — the canonical dbt staging shape
`renamed as (select legacy_qty as qty from {{source}}) select qty from
renamed`. `column_reaches_leaf` early-returned `true` the moment it reached
the leaf-reading node, BEFORE inspecting the inbound edge name, so `qty`
entered source-name-carry under its RENAMED name and emitted
`source.raw_orders.qty` — a Resolved trace to a source field that does not
exist (the real field is `legacy_qty`).

ROBUST fix (preferred — preserves the staging-rename trace-to-source
headline): carry the ORIGINAL source column name through the chain.
`column_reaches_leaf` becomes `resolve_leaf_column` returning the resolved
LEAF-side column name (or `None` to degrade): a `Renamed` edge is followed on
its UPSTREAM column, so `legacy_qty as qty` resolves to the real field
`legacy_qty` across any number of pass-through hops above the rename. The
leaf early-return is gated on resolving that name through the originating
edge; a computed column on a leaf-reading node (a `Derived` inbound) no longer
over-resolves; a non-uniquely-resolvable fork degrades (never a coin-flip).
`ModelOutputs.source_passthrough_columns` becomes a map (output column → real
source field); `source_name_carry` names the edge with the resolved field.

Covers every rename shape via the REAL parse_cte_graph + ProjectColumnGraph
path: rename-in-leaf-reading-CTE, direct-terminal rename, multi-hop
pass-through above a rename, chained rename (a→b→c), `select *` over a
renaming leaf CTE, plus the same-name and computed-column regressions. Explore
goldens regen (+68 cross-model source edges restored, each naming the REAL
source field, zero removed); report goldens byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199AmBCec5kyVEEF1Qd7TSF
…ource field (no literal/computed fabrication)

The cross-model trace-to-source attributed a no-inbound-edge column on a
leaf-reading node to the source under its own name, assuming it flowed
through `select *`. But a LITERAL/computed alias (`42 AS magic`,
`current_timestamp AS t`, `1 AS x`) on a leaf-reading node ALSO has no
inbound edge (the engine emits no edge for a constant), so the fallback
fabricated `source.<leaf>.<alias>` for a column the source does not have.

The star-passthrough fallback in `LeafResolution::into_name` now fires
ONLY when the node ACTUALLY carries a `*` projection over an EXTERNAL leaf
(a recorded `*→*` edge landing on `(node, "*")` whose from-side is not a
sibling CTE — `CteGraph::has_external_star`). A leaf-reading node with no
such star has its edge-less columns degrade to None — a literal/computed
expression is never a source field. Every source attribution now requires
a PROVABLE chain (pass-through/rename edges, or a genuine star over the
leaf) terminating in a column that really exists on the source; every
other shape (literal, computed, derived, join-key, ambiguous fork, opaque)
degrades to None. The R4 robust-rename trace (`legacy_qty AS qty` →
`source.legacy_qty`) is preserved.

Tests (real `parse_cte_graph` + `ProjectColumnGraph::build`): a literal
alias in a leaf-reading CTE never names a source field; assorted
constant/computed aliases in a single model never fabricate a source; the
real pass-through still traces. The literal-CTE shape was RED pre-fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199AmBCec5kyVEEF1Qd7TSF
…ce edges read it, never infer from edge-absence

Replace the read-time star-passthrough INFERENCE (no-inbound-edge +
node-carries-an-external-star ⇒ assume pass-through) with POSITIVE
per-output-column provenance recorded at the projection resolver.

New domain types: ColumnProvenance { DirectColumn, Rename, Literal,
Expression, StarCoveredExternal } + ColumnProvenanceEntry, carried as a
#[serde(skip)] fact on CteGraph (byte-stable goldens). The resolver
classifies every output column; resolve_leaf_column READS the marker — a
no-inbound-edge column is name-carried to a source ONLY when the node's
`*` slot is StarCoveredExternal AND the column is not Literal/Expression.

Closes the literal/expr-paired-with-star fabrication class: `42 as magic,
* from src` and `a.x+1 as y, * from src` no longer fabricate
source.magic / source.y. R4 rename trace-to-real-field + every prior
cross-model behavior preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199AmBCec5kyVEEF1Qd7TSF
…rojection dispatcher under the strict CRAP cap

The added per-column provenance classification pushed resolve_projection_item
to CC 15 (the strict <= 15 boundary). Split the `expr AS alias` arm into a
dedicated resolve_aliased_item helper: dispatcher drops to CC 4, helper is
CC 8 — both comfortably under budget. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199AmBCec5kyVEEF1Qd7TSF
@cmbays
cmbays merged commit 03d2d02 into main Jun 23, 2026
47 checks passed
@cmbays
cmbays deleted the adapters-450-cross-model-trace branch June 23, 2026 18:28
github-actions Bot added a commit that referenced this pull request Jun 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority:soon Important — schedule in the current or next cycle

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cross-model column trace-to-source + downstream impact (v0.2 explorer)

1 participant