Skip to content

feat(AF-941): bytes-scanned cost caps and estimated_bytes_scanned routing - #1104

Merged
babltiga merged 4 commits into
mainfrom
feature/AF-941-bytes-scanned-cost-caps
Sep 25, 2026
Merged

babltiga merged 4 commits into
mainfrom
feature/AF-941-bytes-scanned-cost-caps

Conversation

@babltiga

Copy link
Copy Markdown
Contributor

Closes #941

What

Caps a governed warehouse query by bytes scanned, not only rows returned. It works in two layers:

  • Advisory: an estimated_bytes_scanned routing condition that mirrors estimated_rows and fails closed when there is no estimate.
  • Hard cap (opt-in): datasources.max_bytes_scanned_per_query plus a per-grant bytes_scanned_limit_override, with the most restrictive value winning. It is enforced when the query leaves PENDING_AI and again just before execution, which covers scheduled, recurring, grouped and break-glass runs, and caps lowered after approval.

Decisions on the issue's open questions:

  • No estimate: each datasource makes an explicit choice in bytes_cap_missing_estimate. REQUIRE_REVIEW (the default) holds every automatic approval for a person, the same way a SQL-review BLOCK does. REJECT refuses the query.
  • Engines without dry-run: the cap can only be configured on BigQuery, Snowflake and Databricks. Every other engine gets 422 BYTES_SCANNED_CAP_NOT_SUPPORTED at config time.
  • Rejection message: names both numbers, e.g. "the estimated scan of 2.4 TB exceeds the cap of 1 TB". The query detail page shows the estimate, the limit and where the limit came from.
  • Audit: QUERY_BYTES_SCANNED_CAP_ENFORCED (null actor, stage=decision|execution) is written whenever the cap changed an outcome.

Estimate persistence:

  • query_estimates.estimated_bytes_scanned is now persisted (V192). The AF-634 figure used to be dropped.
  • When AI is skipped, the state machine now computes the estimate before routing. Previously an estimated_rows policy could silently fail closed because it raced the estimate listener.
  • This work runs in its own REQUIRES_NEW transaction, so losing the insert race to that listener can never roll back the decision.

Screenshots

Routing policy builder: estimated bytes scanned condition entered in TB
Datasource limits on a BigQuery datasource: cap and missing-estimate policy
Grant modal: per-grant bytes-scanned cap
Query detail: a query refused by the cap, with the estimate and limit

(The e2e stack has no warehouse, so for shots 2–4 a Postgres datasource was re-typed and the rejected query's cap state was seeded directly.)

Verification

Backend:

  • mvn -o verify -Pcoverage ran 10,578 tests. The only 2 failures were index assertions in AdminAccessSimulationControllerIntegrationTest that had shifted by the new trace step; those are now fixed.
  • After the review fixes I re-ran the affected suites, 276 tests including BytesScannedCapEnforcementIntegrationTest and SqlReviewEnforcementIntegrationTest, plus ApplicationModulesTest, ApiPackageDependencyTest and MessagesParityTest. All green.
  • The full verify was not re-run after those fixes.

Frontend: lint (0 errors), typecheck, test:coverage (2698 tests; 96.1% lines, 87.7% branches) and build are all green.

E2E: the full suite passed locally (387 tests). admin-routing-policies.spec.ts gains two tests: a builder test for the new operand, and a check that the condition does not fire without an estimate.

  • There is no e2e test for the datasource or grant cap fields. They only appear on BigQuery, Snowflake and Databricks, and the e2e stack has no warehouse emulator.
  • Those fields are covered by DatasourceSettingsPage and DatasourceCreateWizardPage component tests and DatasourceControllerIntegrationTest instead.

Docs and website

  • docs: 03-data-model.md, 04-api-spec.md, 05-backend.md (new "Bytes-scanned cost caps" section), 06-frontend.md, 07-security.md.
  • Repo files: README.md, and the CLAUDE.md state-transition block.
  • Website: website/docs/configuration/{datasources,review-workflows,users-roles}/index.html. Their dates were already today's.
  • Help corpus: help-corpus/ regenerated.
  • Deployment guide: no new configuration knob, so docs/09-deployment.md is untouched.

Review notes

Four reviewers ran: af-java-reviewer, af-reviewer, af-frontend-reviewer and af-content-reviewer. I ran the gates myself rather than using af-verifier.

Blockers, both fixed:

  • Estimate race could leave a query in PENDING_AI (af-java-reviewer, af-reviewer). The estimate and cap preparation now run in a REQUIRES_NEW transaction before the decision loads the query. A lost race only fails that inner transaction, and the winner's row is then read.
  • An auto-approved request group bypassed REQUIRE_REVIEW (af-java-reviewer, af-reviewer). A group member with no estimate now runs only if a person approved the group; otherwise it fails with error.bytes_cap.no_estimate_unreviewed.
    • Group member dry-runs now go through QueryCostEstimateService.estimateBytesScanned, which is bounded by accessflow.proxy.estimate-timeout.

Concerns and nits, fixed:

  • Wizard tests added (af-frontend-reviewer).
  • The duplicated permission-table column is now shared (af-frontend-reviewer).
  • The datasource-level cap rule now has an explicit message (af-reviewer).
  • The @ApiResponse 422 texts now mention the new error (af-java-reviewer).
  • Byte sizes in messages use the B unit symbol instead of the English word (af-java-reviewer).
  • The QueryAutoRejectedEvent Javadoc is updated (af-reviewer).
  • The CLAUDE.md and data-model state-transition blocks now include the new transitions (af-reviewer).
  • Website wording is fixed (af-content-reviewer).

Surviving concerns, documented but not changed:

  • Queries approved before a cap existed (af-reviewer). A scheduled or recurring query approved before a cap was configured runs without an estimate under REQUIRE_REVIEW. This is documented in 05-backend.md.
  • Execution-time refusals don't update the query's cap stamp (af-reviewer). A refusal at execution records the reason in error_message but leaves the stamped bytes_scanned_cap columns alone. This is a documented choice.
  • No concurrency test for the race (af-reviewer). There is no multi-threaded test for the estimate race. The fix is covered by unit tests and by the ITs that drive the real AI-skipped path with real transactions.

Follow-ups, out of scope: Terraform provider and bootstrap-spec fields for the new datasource and grant settings.

…ting

Persist the warehouse pre-flight bytes estimate (V192) and let admins cap it
per datasource and per grant (most restrictive wins), refused with 422 on
engines without a bytes estimate. The cap rejects before routing, holds
auto-approvals for review when no estimate exists (REQUIRE_REVIEW) and is
re-checked before execution, groups and break-glass included. Adds the
fail-closed estimated_bytes_scanned routing condition, a BYTES_SCANNED_CAP
trace step and QUERY_BYTES_SCANNED_CAP_ENFORCED audit rows. Estimate prep
runs in its own transaction so a lost insert race never strands a query.
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Frontend Test Results

    1 files    314 suites   12m 28s ⏱️
2 699 tests 2 699 ✅ 0 💤 0 ❌
2 700 runs  2 700 ✅ 0 💤 0 ❌

Results for commit a5caa44.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for Frontend Coverage (frontend)

Status Category Percentage Covered / Total
🟢 Lines 96.1% (🎯 90%) 3724 / 3875
🟢 Statements 94.7% (🎯 90%) 4135 / 4366
🟢 Functions 94.11% (🎯 90%) 1136 / 1207
🟢 Branches 87.71% (🎯 80%) 2500 / 2850
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
frontend/src/components/policies/decisionTraceDetails.ts 98.61% 95.53% 95% 100% 205
frontend/src/pages/admin/routingPolicyForm.ts 98.02% 79.22% 95.23% 99.29% 132, 138, 416
frontend/src/utils/apiErrors.ts 79.21% 71.74% 96.29% 84.84% 108-109, 111, 126-127, 129, 148, 151-152, 154, 170-171, 236, 239, 255, 256, 303-304, 306, 341-343, 345, 361-362, 364, 402-404, 407, 422-423, 446-448, 450, 469, 494-495, 497, 531, 538-557, 601-608, 611
frontend/src/utils/bytesCap.ts 100% 94.11% 100% 100%
frontend/src/utils/enumLabels.ts 98.51% 100% 95.78% 98.5% 284, 292, 722, 780
Generated in workflow #1423 for commit a5caa44 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Backend Test Results

10 590 tests  +82   10 590 ✅ +82   8m 8s ⏱️ - 4m 2s
 1 154 suites + 4        0 💤 ± 0 
 1 154 files   + 4        0 ❌ ± 0 

Results for commit a5caa44. ± Comparison against base commit 6ef991b.

♻️ This comment has been updated with latest results.

@github-actions

Copy link
Copy Markdown
Contributor

Backend Code Coverage

Overall Project 94.88% 🍏
Files changed 99.47% 🍏

File Coverage
BytesCapCheck.java 100% 🍏
QueryDecisionKind.java 100% 🍏
QueryDecision.java 100% 🍏
QueryAutoRejectedEvent.java 100% 🍏
DefaultBytesScannedCapResolutionService.java 100% 🍏
DefaultQueryEstimateService.java 100% 🍏
AccessGrantMaterializer.java 100% 🍏
UpdateDatasourceRequest.java 100% 🍏
CreatePermissionRequest.java 100% 🍏
CreateDatasourceRequest.java 100% 🍏
PermissionResponse.java 100% 🍏
GroupPermissionResponse.java 100% 🍏
CreateGroupPermissionRequest.java 100% 🍏
RoutingConditionEvaluator.java 100% 🍏
ConditionContextFactory.java 100% 🍏
UpdateDatasourceCommand.java 100% 🍏
BytesCapMissingEstimateAction.java 100% 🍏
BytesScannedCapSupport.java 100% 🍏
ByteSizeFormat.java 100% 🍏
QueryEstimateSnapshot.java 100% 🍏
DatasourceGroupPermissionView.java 100% 🍏
AppliedBytesCap.java 100% 🍏
BytesScannedCapSource.java 100% 🍏
CreateDatasourceGroupPermissionCommand.java 100% 🍏
PersistQueryEstimateCommand.java 100% 🍏
BytesScannedCapOutcome.java 100% 🍏
BytesScannedCapExceededException.java 100% 🍏
DatasourceUserPermissionView.java 100% 🍏
QueryDetailView.java 100% 🍏
DatasourcePermissionView.java 100% 🍏
DatasourceAdminException.java 100% 🍏
CreatePermissionCommand.java 100% 🍏
BytesScannedCapNotSupportedException.java 100% 🍏
AuditAction.java 100% 🍏
QueryDecisionStepKind.java 100% 🍏
QueryDecisionEvaluator.java 99.91% 🍏
DefaultAccessSimulationService.java 98.52% 🍏
QueryReviewStateMachine.java 97.26% -0.96% 🍏
DefaultDatasourceUserPermissionLookupService.java 97.17% 🍏
ConditionContext.java 96.45% 🍏
DefaultQueryCostEstimateService.java 96.32% 🍏
QueryDetailResponse.java 96.22% 🍏
ConditionNode.java 96.15% 🍏
AccessSimulationResponse.java 96.04% 🍏
DefaultQueryLifecycleService.java 95.53% -0.06% 🍏
CreateDatasourceCommand.java 95.35% 🍏
DatasourceView.java 95.16% 🍏
DefaultAttestationLifecycleService.java 93.58% 🍏
DefaultQueryRequestStateService.java 93.35% 🍏
GroupExecutionService.java 91.67% 🍏
DatasourcePermissionContribution.java 91.37% 🍏
DatasourceAdminServiceImpl.java 91.15% 🍏
DefaultQueryRequestLookupService.java 90.55% 🍏
DatasourceResponse.java 87.64% 🍏
GlobalExceptionHandler.java 86.44% 🍏
DefaultAiAnalyzerService.java 82.36% -0.39% 🍏
DatasourceController.java 80.15% 🍏

@babltiga
babltiga merged commit cbee5ad into main Sep 25, 2026
35 checks passed
@babltiga
babltiga deleted the feature/AF-941-bytes-scanned-cost-caps branch September 25, 2026 11:28
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.

proxy: bytes-scanned cost caps

1 participant