Skip to content

ACE-083: classify traps by what a fan-out can actually inflate, and give the scope walk a scope - #160

Draft
sandeep-agami wants to merge 1 commit into
agami-governance-branchfrom
spec/ace-083-trap-detection
Draft

ACE-083: classify traps by what a fan-out can actually inflate, and give the scope walk a scope#160
sandeep-agami wants to merge 1 commit into
agami-governance-branchfrom
spec/ace-083-trap-detection

Conversation

@sandeep-agami

Copy link
Copy Markdown
Collaborator

Spec: ACE-083

The fan-trap detector decides from (a one-side column appears in some aggregate) × (one-to-many cardinality) rather than from (a duplication-sensitive rollup of a one-side measure at one-side grain) × (one-to-many cardinality). Three fixes restore the missing terms. Closes #151.

What was wrong

Measured through pre_flight_check against the test fixture model:

query before why it is wrong
COUNT(DISTINCT orders.id) FROM orders JOIN order_items … fan_trap DISTINCT de-duplicates; a fan-out cannot inflate it
MAX(orders.total_amount) over the same shape fan_trap idempotent under row duplication
SUM(order_items.quantity * orders.total_amount) fan_trap the aggregated value is at many-side grain; the one-side column is a scalar co-factor
pre-aggregated CTE, then join fan_trap the CTE is grouped to the join key, so the join is 1:1 — and this is the shape the refusal message itself recommends
WITH o AS (SELECT * FROM orders) SELECT SUM(o.total_amount) FROM o JOIN order_items … allow genuinely inflated, and it passed

The last two share one root cause: _tables_in_scope was a flat find_all(exp.Table) over the whole tree with no concept of scope, so CTE-body tables leaked into the outer query (false refusals) while CTE aliases never resolved to their underlying table (false allows).

Changes

  • Fan-immune aggregatesMIN, MAX, DISTINCT-qualified aggregates and boolean folds no longer feed the fan rule. Matched on the sqlglot node type and the distinct arg, not a name string, so it survives a dialect spelling it differently. SUM, AVG, COUNT(*), COUNT(col), STRING_AGG and ARRAY_AGG still trip it — one test each so the exemption cannot silently widen. AVG is the one worth naming: a fan duplicates rows unevenly, so averages do shift.
  • Measure vs co-factor — an aggregate is attributed to the one side only when a one-side column is the aggregated value.
  • Scope_tables_in_scope is outer-scope only; a CTE alias resolves to its underlying table only when the CTE preserves grain (no GROUP BY / DISTINCT / aggregate), transitively; a grain-changing CTE keeps its own grain instead of inheriting the source table's cardinality; anything unclassifiable yields certainty="uncertain" rather than a silent allow.

This changes what counts as a trap, never what happens once one is found. The auto_rewrite branch is untouched.

Verification

dev.py check: 2304 passed, 95 skipped, 2 xfailed; ruff + gitleaks clean; no vendored-lib drift.

Collected-test delta is exactly the new cases: 2377 → 2401.

Red-on-base, measured rather than asserted — base source with the new tests: 16 failed, 8 passed. With the fix: 24 passed. The 8 passing on base are the guardrail assertions (the fan-sensitive aggregates that must still refuse), which are supposed to be green on base.

…nd give the scope walk a scope

The fan rule decided from "a one-side column appears in some aggregate" x
"one-to-many cardinality" rather than from "a duplication-sensitive rollup of a
one-side measure" x "one-to-many cardinality". Three consequences, two costing
correct queries a refusal and one letting an inflated one through clean.

1. Fan-immune aggregates no longer feed the fan rule. MIN/MAX, any
   DISTINCT-qualified aggregate and the boolean folds are idempotent under row
   duplication, so a fan-out cannot change their value and the query was already
   right. Decided on the parsed node (type + the distinct argument), never on a
   name string: this gate reads each statement in its datasource's own grammar,
   and a name allowlist breaks on the first engine that spells an aggregate
   differently. SUM, AVG, COUNT(col) and the ordered/array aggregates stay
   in-scope, with one test each so the exemption cannot widen unnoticed.

2. A one-side column inside an aggregate is no longer confused with the one-side
   column being aggregated. SUM(order_items.quantity * orders.total_amount) sums
   one value per many-side row that a per-order rate scales; nothing is
   duplicated. The genuine trap names no many-side column, so it still fires.

3. The scope walk stops at a CTE body. It was a flat find_all over the whole
   tree, which broke in both directions: tables leaked OUT of a CTE body, so
   pre-aggregating a measure in a CTE before joining -- the remediation this
   guard itself recommends -- was refused; and a CTE alias never resolved, so
   wrapping a table in a pass-through WITH hid the one side entirely and an
   inflated total came back clean. A CTE alias now resolves to its underlying
   table only when the body preserves grain, transitively; a grain-changing CTE
   is its own entity and does not inherit the source table's cardinality; and a
   body that joins, unions, or reads a table the model does not declare does not
   resolve at all. That last case no longer returns a bare allow -- PreFlightResult
   carries a certainty, and an unprovable lineage says so instead of implying the
   query was checked.

The two callers that ask a coverage question rather than a cardinality one --
the receipt's provenance list and the row-scoping pass -- keep the flat walk
under its own name, so neither loses a table.

No verdict changed across the safety corpus or the shipped example queries.

Spec: ACE-083

Copilot AI 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.

Pull request overview

This PR tightens the SQL pre-flight fan-trap classifier to reduce false refusals by distinguishing duplication-immune aggregates and co-factor usage, and it introduces a scope-aware table walk so CTE bodies don’t leak into outer cardinality analysis while grain-preserving CTE aliases can still resolve transitively.

Changes:

  • Exempt duplication-immune aggregates (e.g., MIN/MAX, DISTINCT-qualified, boolean folds) from the fan-trap rule, and avoid treating one-side scalar co-factors as “one-side measures.”
  • Add scope-aware table binding for cardinality analysis, including CTE alias resolution only when the CTE preserves grain (with transitive resolution).
  • Add a comprehensive test suite covering fan-immune vs fan-sensitive aggregates, co-factor cases, and CTE scoping/grain behavior, plus an “uncertain” certainty mode when lineage can’t be established.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
tests/test_semantic_model_runtime.py Adds regression tests for fan-trap precision (immune vs sensitive aggregates), measure-vs-co-factor attribution, and CTE scope/grain handling.
packages/agami-core/src/semantic_model/runtime.py Refines fan-trap detection logic, introduces scope-aware table walking with CTE grain classification, and adds certainty to pre-flight results.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +2069 to +2074
def _preserves_grain(body: "exp.Select") -> bool:
"""Is `body` one output row per source row? A WHERE or a LIMIT still is; a GROUP BY, a
SELECT DISTINCT or an aggregate is not. Matched by node type, per `_own_child`."""
if _own_child(body, (exp.Group, exp.Distinct)) is not None:
return False
return body.find(exp.AggFunc) is None
Comment on lines +2147 to +2151
return {
table
for _alias, table, grain in _scope_bindings(tree)
if grain is not None and (grain.opaque or table not in known_tables)
}
@sandeep-agami
sandeep-agami marked this pull request as draft July 29, 2026 00:24
@sandeep-agami

Copy link
Copy Markdown
Collaborator Author

Converting to draft — the loosening is unbounded in four shapes

Review found, and I independently reproduced through pre_flight_check against both branches, four queries that refuse on base and are allowed here. All four are genuinely inflated. All four are labelled certainty="provable".

query base this branch
SUM(CASE WHEN order_items.quantity > 0 THEN orders.total_amount ELSE 0 END) refuse allow / provable
STRING_AGG(orders.status, ',' ORDER BY order_items.id) refuse allow / provable
WITH oi AS (SELECT order_id, product_id, SUM(quantity) … GROUP BY order_id, product_id) … JOIN oi ON oi.order_id = orders.id refuse allow / provable
WITH oi AS (SELECT DISTINCT order_id, product_id …) … JOIN oi ON oi.order_id = orders.id refuse allow / provable

Why each is wrong

The first two — the co-factor rule is too broad. _reads_any_table is a bare find_all(exp.Column) over the whole aggregate node, so any syntactic occurrence of a many-side column exempts the aggregate. The rule's justification holds when the many-side column is a factor of the value (quantity * total_amount). It does not hold when the column is a boolean selector or an ordering key: in the CASE query the aggregated value is orders.total_amount, and an order with three qualifying line items contributes it three times. Conditional aggregation over a fan is how every "revenue of orders containing product X" query is written — this is the shape most likely to reach a customer.

The second two — a grain-changing CTE is assumed to sit at the join key's grain, and never checked against it. oi is one row per (order, product), not per order, so the join still fans. Nothing compares the CTE's GROUP BY / DISTINCT keys to the columns it is joined on. This is a strictly easier bypass than the WITH o AS (SELECT * FROM orders) shape the change set out to close, because it survives the new scope walk by design.

Two more, same root

SELECT SUM(d.total_amount) FROM (SELECT * FROM orders) d JOIN order_items … — the derived-table spelling of the under-detection case — is allow / provable. Deferring derived-table lineage is a legitimate scope call; labelling it provable is not, since that is the field the tiered policy will key enforcement off.

And certainty reaches no surface: no caller reads it, sm prepare drops the reason when risk is None, and both receipt builders gate on if pre_flight.risk:. So the uncertain path — the whole answer to "never a silent allow" — is behaviourally identical to base's silent allow. The success criterion passes because it asserts on the field, not on what happens.

My error, not the reviewer's find

The non-regression criterion I wrote only guarded allow → refuse. Every one of these is refuse → allow, which nothing in the spec covered and which the corpus would not catch. Loosening fixes need the reverse assertion, and the spec asked for it in only one direction.

Next commit adds that reverse assertion first, then narrows the two rules.

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.

2 participants