Skip to content

fix(impact): close fail-open gaps in the offline gate (logic diff, catalog freshness, scope-git, selection) - #141

Open
Fszta wants to merge 11 commits into
mainfrom
fix/impact-gate-fail-open
Open

Fszta wants to merge 11 commits into
mainfrom
fix/impact-gate-fail-open

Conversation

@Fszta

@Fszta Fszta commented Sep 28, 2026

Copy link
Copy Markdown
Owner

Summary

Closes a set of fail-open gaps in the offline impact gate — cases where a genuinely-affected change could be classified SAFE or skipped from a selective build with no visible warning. Every fix moves an unprovable state to widen / degrade / surface, never to a silent pass, and keeps the gate's exit codes unchanged.

What was broken and fixed

  • Logic diff gated on a comment-strip that wasn't string-literal-aware. A -- or /* */ inside a quoted string literal made two different compiled SQLs compare equal, so a real value change was reported SAFE. The gate now uses the already-sound, string-literal-preserving token signature; anything untokenizable is treated as changed (fail-safe).
  • Missing compiled SQL read as "no change." A model with no compiled SQL on either side silently reported no logic change and wouldn't even rebuild itself. It now surfaces an explicit indeterminate change that degrades impact confidence to partial and widens the rebuild selection.
  • Structural diff blind to a stale/duplicated catalog. With one catalog.json mounted on both diff sides (a common CI shape), removed/retyped columns are invisible and --fail-on tests can never fire — with no warning. The report now stamps structural_diff: degraded (with a reason and a visible honesty line) when catalogs are content-identical or the head catalog predates the head manifest. Advisory only; verdicts/exit codes unchanged.
  • Unresolved changes didn't widen the selection. A change whose impact fan-out couldn't be computed contributed nothing, so its unknown downstream cone landed in skippable_models. Any unresolved change now forces the widen branch.
  • Seed dependencies dropped from the DAG. get_model_upstream ignored seed.* deps, so a model built off a dbt seed had no upstream edge and a seed change reached nothing. Fixed.
  • --scope-git silently dropped non-model changes. A macro / Python-model / dbt_project.yml / seed change mapped to no model .sql, so scoping emptied the changeset to a SAFE verdict and skipped every rebuild. Scoping now classifies the full diff: known macro files scope to their (transitive) dependent models, and any remaining logic-bearing file that can't be mapped disables scoping for the run with the reason surfaced on stderr, in the report source, and in a typed scope_git report block.
  • Selective-build observability. The backtest now reports selection widen-rate stats (widen %, median skippable models, forced-rebuild total, ranked reasons) so the widen behavior above can be measured over real history. Measurement only — no gating changes.
  • Docs: honest note that tests attached to skipped models won't run in a selective build, with mitigations.

Machine-output changes (additive)

New/extended fields on the JSON report and pydantic schema: logic_diff_status, confidence.indeterminate_logic(_models), structural_diff {status, reason}, scope_git block, policy verdict unproven/unproven_count, backtest selection_stats, and a top-level report_version + parrant_version stamp. A new default warn_rules_fire_on_unknown (off) lets warn rules fire on an unknown leaf; default-off behavior is byte-identical apart from the new telemetry.

Testing

Every fix is covered by a test that fails without it. Full suite green on this branch: 669 unit + 116 integration + 31 e2e. No new type-check errors versus the current baseline (the pre-existing mypy errors are addressed separately in the quality-gate PR).

🤖 Generated with Claude Code

Fszta and others added 11 commits September 28, 2026 20:53
Measurement only — zero selection/gating behavior changes. Each replayed
point now records the Selection facts it already computed (widened flag,
skippable count, rebuild_forced_by_nonresolution, resolution reasons) and
the report aggregates them: widen-rate %, median skippable models, forced
rebuild total, and the ranked widening/forcing-reason breakdown. Rendered
as one Selection line (+reasons) in the table/markdown output and as a
typed selection_stats block in JSON; omitted (None) when no point carried
a selection so an absent surface is never read as a 0% rate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Under on_missing_meta/on_error fail_closed, an UNKNOWN leaf only fires a
BLOCKING rule; a warn rule silently ALLOWs, so an all-warn pilot policy
systematically understates what block mode will do (issue #124, option 2).

Record each suppressed firing as an UnprovenPolicyHit (rule, subject,
unknown cause, unproven leaf conditions) carried on PolicyVerdict as
unproven/unproven_count. Surfaced in the JSON verdict, and as an additive
'N unproven warn-rule conditions' section (list folded) in the Markdown
report / sticky PR comment. Report-only: decision, hits, and exit codes
are byte-identical to before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add defaults.warn_rules_fire_on_unknown (bool, default OFF): when set,
fail_closed applies to non-blocking rules too, so a warn rule whose
predicate stays UNKNOWN fires at its declared severity as a normal warn
hit marked fired_on_unknown, instead of landing in the unproven
telemetry. It never escalates severity (warn stays warn, never block),
so --fail-on policy exit behavior is untouched; default-off behavior is
byte-identical apart from the unproven telemetry. Option 1 of issue #124,
behind the knob the issue proposed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ature

The model-level logic gate compared compiled SQL after a regex comment strip
(strip_sql_comments), which is not string-literal-aware: a -- or /* inside a
quoted string swallowed the real tokens around it, so a genuine change (e.g.
amount * 1 -> amount * 100 on the same line as a '-- in a string') compared
equal and was silently reported SAFE. Gate on the already-sound, lru-cached
comment_free_token_signature instead — the tokenizer preserves string
contents, so equal signatures prove a comment/whitespace-only edit and
anything unprovable (untokenizable side) is treated as changed (fail-safe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A MODEL whose compiled SQL was unavailable on either side silently reported
'no logic change' — the edit was invisible and the model would not even
rebuild itself. The model-level logic diff is now three-state
(LogicDiffStatus in models/schema.py): sources/seeds/snapshots keep the
legitimate no-op, but a model node with missing SQL emits fail-safe
LOGIC_CHANGED/INDETERMINATE changes for every head column (a '*' sentinel
when the model is column-less) carrying logic_diff_status='indeterminate'.
The marker flows into the JSON report, into a new
confidence.indeterminate_logic(_models) block that degrades the impact
confidence to 'partial', and — through the existing widen branch — empties
skippable_models so nothing downstream of an un-diffable model is skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…catalogs

structural_diff_available() only checks catalog PRESENCE, so with one prod
catalog.json mounted on both diff sides (the real deployment shape) the
structural checks nominally ran while being structurally blind: identical
catalogs can never show a removed/retyped column, provable_break_count is
stuck at 0, --fail-on tests can never fire, and nothing warned.

detect_structural_degradation(base, head) now compares catalog content
fingerprints (sha256 over canonical JSON — byte-formatting-insensitive) and
the head catalog/manifest generated_at + invocation_id stamps: identical
catalogs, or a head catalog predating the head manifest from a different dbt
invocation, stamp the report with structural_diff {status: degraded, reason}
plus summary.structural_diff, and the markdown/PR-comment renderer prints a
visible honesty line. Advisory only — exit codes and verdicts unchanged; the
key is absent when nothing is provable (stub providers make no claim).

Also adds report_version: 1 and parrant_version to the JSON report top level
so machine consumers can pin the shape they parse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
build_selection folded reach from RESOLVED changes only: a change whose
impact fan-out raised (unresolved) contributed nothing to breaking_reached,
so its unknown downstream cone landed in skippable_models — an unprovable
state classifying models safe to skip. Any unresolved change in by_change
now forces the widen branch (rebuild = whole reachable universe, skippable
empty), exactly like non-full confidence or a truncated list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
get_model_upstream handled model/source/snapshot dep prefixes but not seed:
a model ref()ing a seed recorded no upstream edge, so the seed's consumers
were invisible to reachability — and therefore to impact and the rebuild
selection — whenever a seed changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rebuild selector selects models, and dbt only runs tests attached to
selected models: a relationships test attached to a skipped model that
references a rebuilt one will not run in a selective CI build. Document the
blind spot honestly on the selective-builds section, with concrete
mitigations (--indirect-selection=buildable, selector+1, scheduled full
dbt test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed to models

--scope-git intersected the two-manifest changeset with models mapped from
changed *.sql files only. A diff whose vehicle is not a model .sql file — a
macro, a Python model, dbt_project.yml vars, packages.yml, a seed .csv — was
silently dropped from the scope set, emptying the changeset into a SAFE
verdict and skipping every CI rebuild (fail-open).

resolve_git_scope now classifies the FULL git diff: model files map via
resource_path (.py models and seeds included), macro files map via the
backend's macro-dependents capability when available, and any remaining
logic-bearing file is UNMAPPABLE — scoping is then disabled for the run
(full changeset kept) with the reason surfaced on stderr, in the report
source line, and as the pydantic-shaped report["scope_git"] block.
Docs/property-only files (schema.yml, *.md) cannot change compiled SQL and
still narrow legitimately. Exit-code semantics unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-git

ManifestReader.get_macro_dependents builds macro-file -> dependent-node map
from manifest macros[*].original_file_path, nodes[*].depends_on.macros and
the transitive (cycle-safe) macros[*].depends_on.macros graph; ModelRegistry
exposes it as a cached capability method. resolve_git_scope picks it up via
its capability lookup, so a changed macro file scopes the changeset to the
models it (transitively) feeds instead of disabling scoping outright. An
unknown macro file still falls back to the fail-safe floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant