Skip to content

feat: serve per-instance query limits in V1 proofs and lift them through merges - #845

Merged
QuantumExplorer merged 4 commits into
developfrom
claude/query-instance-limit-proofs
Aug 31, 2026
Merged

feat: serve per-instance query limits in V1 proofs and lift them through merges#845
QuantumExplorer merged 4 commits into
developfrom
claude/query-instance-limit-proofs

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Aug 31, 2026

Copy link
Copy Markdown
Member

Part 2 of 3, stacked on #844 (merge that first; this PR's base is its branch). Part 3 is the book chapter.

V1 prover/verifier accounting

The bare overall_limit: &mut Option<u16> / limit_left counters become a shared V1LimitState { global, consumed_rows, consumed_total } threaded through both walks, plus a frame-local instance budget per layer: min(inherited remaining, the layer query's own Query::limit), resolved through the new SinglePathSubquery::instance_limit (so synthesized path-component layers pass the chain through untouched, and the chain starts at the query root automatically).

  • Each layer's merk walk runs under min(global, instance) — merk's per-layer "proof returns more data than limit" check is what enforces per-instance over-delivery on the verifier side (pinned by an adversarial test: a proof built for cap 4 must not verify under cap 2).
  • Rows charge every budget; descents settle the enclosing frame from the consumed_rows delta; the empty-layer charge (decrease_limit_on_empty_sub_query_result) stays global-only — it bounds traversal work, instance caps bound rows. (No new ProveOptions field: V0 proofs bincode-embed ProveOptions, so a new field there would break the frozen wire format, and the V1 verifier always runs ProveOptions::default() anyway.)
  • consumed_total also replaces the old detect-consumption-by-diffing-the-Option trick, which went blind when the global limit was None — a real case once instance caps can exist without a global one.
  • Non-Merk layers (MMR / BulkAppend / Dense / CommitmentTree) and the count-offset dispatch keep their helper signatures; they're bounded through an effective-limit temp whose delta is absorbed back into the shared state.

Behavior for every query without per-instance limits is unchanged — the full workspace suite (5125 tests) passes untouched, which matters because the V1 proof format is live under GROVE_V3. The V0 prover/verifier stay frozen and reject limit-carrying queries at dispatch; absence-proof assembly and pre-V4 versions keep failing closed.

Merge lifting (path_query_methods.merge = 2, GROVE_V4)

PathQuery::merge now merges limited path queries by lifting: an input's global SizedQuery::limit becomes its merged branch's Query::limit. The lift is semantically exact — the branch instance executes exactly once (its path is a concrete key chain), so "at most N rows from this input" and "at most N per instance" coincide. Authored per-instance limits ride along on their branches. Per the refuse-on-collision decision:

  • a limited input landing at the merged root (its body would blend with other inputs' queries) is refused with a typed error;
  • two limited branches colliding on a key are refused (limits merge only as exclusive grafts);
  • limit-free inputs keep the v1 merge behavior identically (same code path).

prove_query_many therefore now serves limited path queries, and the verifier re-derives the identical merged query at the same grove version.

Deliberately out of scope

Counting add_parent_tree_on_subquery rows against the limit (the M6 known limitation) is not included. Making the prover charge exactly where the verifier pushes parent rows requires auditing every descent arm (ordinary, cidx-family, the four non-Merk arms, and the aggregate descents, where the pairing is unclear) — an asymmetry there breaks honest proofs. It deserves its own focused change; the documented limitation stands.

Tests

per_instance_limit_tests.rs grows to 21: proof round-trips differentially checked against the trusted read (top-k per parent, global∧instance composition, conditional-branch caps, ancestor caps, reverse direction), the wide-cap-vs-tight-cap adversarial rejection, absence-mode rejection, pre-V4 prover rejection, merge lift round-trips (instance-capped and global-limited inputs through prove/verify), and the collision/root-landing refusals. merge_refuses_limits_and_offsets_at_every_merge_version becomes merge_refuses_offsets_at_every_version_and_lifts_limits_from_v2, pinning the pre-v2 refusal and the v2 lifted structure.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for per-instance query limits in GROVE_V4 trusted reads, V1 proofs, and query merging.
    • Query limits can provide a fresh result budget for each execution instance, such as “top K per parent.”
    • Limits compose with global budgets and are preserved when supported queries are merged.
  • Bug Fixes

    • Improved proof generation and verification to correctly enforce global and per-instance limits.
  • Documentation

    • Added comprehensive guidance covering query-limit behavior, version support, merging, and unsupported scenarios.

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

Next included review available in 19 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a5f42063-1648-4f9b-a2c5-58a387a443dd

📥 Commits

Reviewing files that changed from the base of the PR and between be807bd and ed50f67.

📒 Files selected for processing (8)
  • docs/book/src/per-instance-limits.md
  • grovedb-version/src/version/v4.rs
  • grovedb/src/query/merge/mod.rs
  • grovedb/src/query/merge/v0.rs
  • grovedb/src/query/merge/v1.rs
  • grovedb/src/query/mod.rs
  • grovedb/src/tests/merge_versioning_tests.rs
  • grovedb/src/tests/per_instance_limit_tests.rs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e111b64f-ed24-46f9-93db-a7d28c89fca5

📥 Commits

Reviewing files that changed from the base of the PR and between b831ec6 and be807bd.

📒 Files selected for processing (15)
  • docs/book/src/SUMMARY.md
  • docs/book/src/per-instance-limits.md
  • docs/book/src/query-system.md
  • docs/crates/grovedb.md
  • grovedb-query/src/query.rs
  • grovedb-version/src/version/grovedb_versions.rs
  • grovedb-version/src/version/v4.rs
  • grovedb/src/operations/proof/generate.rs
  • grovedb/src/operations/proof/mod.rs
  • grovedb/src/operations/proof/verify.rs
  • grovedb/src/query/mod.rs
  • grovedb/src/tests/merge_versioning_tests.rs
  • grovedb/src/tests/per_instance_gate_coverage_tests.rs
  • grovedb/src/tests/per_instance_limit_tests.rs
  • grovedb/src/tests/proof_depth_limit_tests.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change adds Query::limit support for V1 proofs and GROVE_V4 trusted reads. It introduces shared proof-limit accounting, enables merge-v2 limit lifting, adds fail-closed gates, updates version and query documentation, and expands regression coverage.

Changes

Per-instance query limits

Layer / File(s) Summary
Contracts, documentation, and version gates
docs/book/src/*, docs/crates/grovedb.md, grovedb-query/src/query.rs, grovedb-version/src/version/*
Documents per-instance and global limits, supported and rejected query paths, wire behavior, merge versioning, and the parent-tree limitation.
PathQuery merge semantics
grovedb/src/query/mod.rs, grovedb/src/tests/merge_versioning_tests.rs
Merge version 2 lifts input global limits onto exclusive branch instance caps. Root-landing and colliding limited branches remain rejected.
V1 proof limit accounting
grovedb/src/operations/proof/mod.rs, grovedb/src/operations/proof/generate.rs
Adds V1LimitState and threads global and inherited per-instance budgets through V1 proof generation and tree descents.
V1 proof verification and rejection gates
grovedb/src/operations/proof/verify.rs
Applies supported-version checks and replays frame-local limit accounting across Merk, indexed, aggregate, and non-Merk proof layers.
Limit behavior regression coverage
grovedb/src/tests/per_instance_limit_tests.rs, grovedb/src/tests/per_instance_gate_coverage_tests.rs, grovedb/src/tests/proof_depth_limit_tests.rs
Tests supported proof paths, nested caps, mismatched caps, unsupported cases, merge behavior, and updated depth-limit interfaces.

Estimated code review effort: 5 (Critical) | ~90+ minutes

Merge Risk: 🔵 Low · up to be807

Per-instance limits are enforced for Merk-backed queries, but several non-Merk proof types still construct or verify the full proof before capping returned rows. Large proofs could therefore consume more processing and memory than the configured limit; the change is mergeable with explicit owner awareness and follow-up on bounded proof processing.

Sequence Diagram(s)

sequenceDiagram
  participant QueryCaller
  participant PathQuery
  participant V1Prover
  participant V1LimitState
  participant V1Verifier
  QueryCaller->>PathQuery: provide Query::limit
  PathQuery->>V1Prover: generate V1 proof
  V1Prover->>V1LimitState: derive and charge frame budgets
  V1Prover-->>QueryCaller: return proof bytes
  QueryCaller->>V1Verifier: verify proof with same limit
  V1Verifier->>V1LimitState: replay row and descent charges
  V1Verifier-->>QueryCaller: return verified rows
Loading
🚥 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 clearly and concisely summarizes the main changes: per-instance query limits in V1 proofs and limit lifting through merges.
Docstring Coverage ✅ Passed Docstring coverage is 82.22% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 45 functions across 11 files. (4 skipped: 4…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 82.22% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 45 functions across 11 files. (4 skipped: 4 unsupported.)

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/query-instance-limit-proofs

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.

Base automatically changed from claude/query-nested-limit-7ca6d5 to develop August 31, 2026 18:29
…ugh merges

Teaches the V1 prover and verifier the per-instance limit accounting
(Query::limit, introduced in #844), and makes PathQuery::merge lift
merged inputs' global limits into per-instance caps.

Proofs: the bare overall_limit/limit_left counters become a shared
V1LimitState { global, consumed_rows, consumed_total } threaded through
both walks, plus a frame-local instance budget per layer
(min(inherited, the layer query's Query::limit), resolved through
SinglePathSubquery::instance_limit). Each layer's merk walk runs under
min(global, instance); rows charge every budget, descents settle the
enclosing frame from the consumed_rows delta, and the empty-layer
charge stays global-only. The consumed_total counter also replaces the
old detect-consumption-by-diffing-the-Option trick, which went blind
when the global limit was None. Non-Merk layers (MMR / BulkAppend /
Dense / CommitmentTree) and the count-offset dispatch keep their helper
signatures and are bounded through an effective-limit temp whose delta
is absorbed back. Behavior for every query without per-instance limits
is byte-for-byte unchanged; the V0 prover/verifier stay frozen and
reject limit-carrying queries at dispatch, as do absence-proof assembly
and pre-GROVE_V4 versions. The public verification wrappers run the
version-aware gate before decoding the proof envelope, with the
absence-mode blanket kept pre-decode as well.

Merging (path_query_methods.merge = 2, GROVE_V4): an input's global
SizedQuery::limit is lifted onto its merged branch's Query::limit —
exact, because the branch instance executes exactly once — and authored
per-instance limits ride along on their branches. Limits merge only as
exclusive grafts: a limited input landing at the merged root, or two
branches colliding on a key, are refused with typed errors; limit-free
inputs keep the v1 merge behavior identically. prove_query_many
therefore now serves limited path queries, and the verifier re-derives
the same merged query at the same grove version.

Deliberately NOT included: counting add_parent_tree_on_subquery rows
against the limit (the M6 known limitation). Making the prover charge
exactly where the verifier pushes needs a per-descent-arm audit
(including the aggregate arms) that deserves its own change; the
documented limitation stands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer
QuantumExplorer force-pushed the claude/query-instance-limit-proofs branch from 8456217 to 3d17adc Compare August 31, 2026 18:36
Adds the Per-Instance Query Limits chapter (semantics, min composition,
budget-vs-traversal charges, the merge lift, proof behavior, the
fail-closed table, and the version-3 wire encoding), links it from the
Query System chapter, and refreshes the stale Query struct listings in
the book and the grovedb crate doc (both predated read_mode). The
add_parent_tree_on_subquery known-limitation note now points at the
remaining prover/verifier charging work instead of promising the
per-level redesign that has since shipped.

Part 3 of 3.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.26877% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.55%. Comparing base (b831ec6) to head (ed50f67).

Files with missing lines Patch % Lines
grovedb/src/query/merge/v0.rs 84.74% 9 Missing ⚠️
grovedb/src/query/merge/mod.rs 73.07% 7 Missing ⚠️
grovedb/src/operations/proof/generate.rs 95.55% 6 Missing ⚠️
grovedb/src/operations/proof/verify.rs 96.69% 4 Missing ⚠️
grovedb/src/query/merge/v1.rs 96.90% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #845      +/-   ##
===========================================
- Coverage    92.55%   92.55%   -0.01%     
===========================================
  Files          293      296       +3     
  Lines        91035    91344     +309     
===========================================
+ Hits         84256    84540     +284     
- Misses        6779     6804      +25     
Components Coverage Δ
grovedb-core 90.70% <94.26%> (+<0.01%) ⬆️
merk 93.27% <ø> (ø)
storage 91.95% <ø> (ø)
commitment-tree 96.38% <ø> (ø)
mmr 95.12% <ø> (ø)
bulk-append-tree 92.75% <ø> (ø)
element 97.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

QuantumExplorer and others added 2 commits August 31, 2026 21:19
…pattern

The merge slot's behavior was branched inline throughout one function
body (if merge_version >= 1 / < 2 / match merge_version), against the
repo convention that version gates live in <op>/{mod.rs,v0.rs,...}
sub-files with a match dispatcher. PathQuery::merge now keeps only the
version-independent prelude (unknown-slot fail-closed validation,
empty-input rejection, the read-mode refusal, the single-input
shortcut) and dispatches to query/merge/{v0,v1,v2}.rs:

- v0: frozen GROVE_V1..V3 behavior — directions silently dropped, all
  limits and offsets refused;
- v1: direction agreement + propagation, limits still refused (kept as
  its own arm — unlike the folded path_query_push intermediate, the
  0/1/2 numbering is spelled out in the version-table docs);
- v2 (GROVE_V4): v1's direction rules plus the per-instance limit lift
  with its exclusive-graft refusals.

Pure restructure — every version's behavior is byte-for-byte what the
inline branches produced, pinned by the untouched merge test suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same reasoning as the path_query_push fold: the direction-agreement
merge landed gated to GROVE_V4 as slot 1, V4 has not been released, and
the per-instance lift took the V4 slot as 2 — leaving slot 1 a dead
dispatch arm no version table selects. Since the slot values are
internal until V4 ships, collapse: the direction rules + limit lift ARE
merge v1 now, GROVE_V4 maps path_query_methods.merge back to 1, and the
dispatcher (and its fail-closed validator) know [0, 1]. Table docs,
tests, and the book chapter renumbered to match.

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

@QuantumExplorer QuantumExplorer left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Approved

@QuantumExplorer
QuantumExplorer merged commit 33a3ad3 into develop Aug 31, 2026
13 checks passed
@QuantumExplorer
QuantumExplorer deleted the claude/query-instance-limit-proofs branch August 31, 2026 19:32

@QuantumExplorer QuantumExplorer left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reviewed the final merged head ed50f67755c00c65ec8142a027a4b5e5105a4bbf. Documentation note (P3): grovedb-version/src/version/v4.rs:91-99 still says per-instance limits are served only on trusted reads and that proofs reject them. That contradicts this PR's V1 prover/verifier support and the updated generic capability documentation; please update the V4 capability summary as well.

Comment thread grovedb/src/operations/proof/generate.rs
Comment thread grovedb/src/operations/proof/generate.rs
Comment thread grovedb/src/operations/proof/generate.rs
Comment thread grovedb/src/operations/proof/generate.rs
Comment thread grovedb/src/operations/proof/generate.rs
Comment thread grovedb/src/operations/proof/generate.rs
Comment thread grovedb/src/query/merge/v1.rs
QuantumExplorer added a commit that referenced this pull request Aug 31, 2026
* fix: address maintainer P1 review on per-instance limits in proofs

- A layer with subquery branches no longer runs its merk walk under the
  instance budget: the cap counts descendant ROWS, and an empty first
  child consumes none of it, so truncating the child enumeration
  discarded later populated children (proofs returned 0 rows where the
  trusted read returned 1). Subquery layers now walk under the global
  budget only, pure terminal layers keep min(global, instance), and the
  verifier derives the identical per-layer value.
- That exposed a truncation-masked prover/verifier asymmetry on
  subquery-matched EMPTY trees: the prover charged them as result rows
  (instance included), the verifier charged nothing. Both sides now
  charge them as empty children — global budget only, mirroring the
  trusted read's empty-subquery charge — while genuinely-terminal empty
  trees keep their row charge.
- The four non-Merk adapters (MMR / BulkAppend / Dense / CommitmentTree)
  min-compose the lower query's own Query::limit on both prover and
  verifier; they bypass recursive frame creation, so a child cap was
  ignored entirely (3 rows verified under a cap of 1).
- The BulkAppend layer accounting saturates on an empty child range
  (positions at or past total_count clamped end below start —
  debug-panic underflow, ~u16::MAX charged in release).
- The merge lift treats overlap with the merged root's own selection as
  a collision: a grafted conditional overrides the root query's
  default/terminal semantics for that key and silently dropped the
  root-landing input's contribution.

Regression tests: empty-then-populated sibling parity under an instance
cap, child caps enforced across Dense/Bulk/MMR proofs, the empty-range
underflow, and the root-overlap merge refusal.

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

* docs: tidy merge v1 comments after the fold

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

* fix: follow-ups from the per-instance limits reviews (#844/#845)

Carries the review fixes the #845 squash-merge raced past, plus the
follow-ups from the post-merge #844 review.

Proof/merge fixes (reviewed on #845, missed by its merge):
- subquery layers no longer run their merk walk under the instance
  budget (an empty first child discarded later populated ones); pure
  terminal layers keep min(global, instance); verifier mirrors the rule
- prover and verifier both charge subquery-matched EMPTY trees as empty
  children (global budget only) — truncation had masked their
  row-vs-nothing asymmetry
- the non-Merk adapters (MMR/Bulk/Dense/CT) min-compose the lower
  query's own Query::limit on both sides
- the BulkAppend layer accounting saturates on an empty child range
  (debug-panic underflow)
- the merge lift treats overlap with the merged root's own selection as
  a collision

#844 follow-ups:
- Query's serde representation is versioned and hand-written: fixed
  always-present positional layout with a leading version; flat
  base-compatible JSON for versions 1-2; the limit-bearing version 3
  nests under `body` so pre-limit readers hard-fail instead of silently
  decoding an unlimited query; unknown flat keys are refused; canonical
  version claims enforced; cross-generation writer/reader tests both
  directions in both format families
- Element::query_item runs the recursive whole-query preflight (an
  unmatched limited conditional made acceptance data-dependent)
- the internal walk helpers validate their subordinate method-version
  slots (query_item / get_query_apply_function / get_path_query), which
  the wrapper-only checks no longer covered
- both coherence gates exact-match the path_query_push selector, so an
  unknown engine value is a typed VersionError independent of data

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

* fix: address the six Codex findings on the follow-ups PR

- Serde: the positional layout is now FRAMED — a leading magic sentinel
  before the version, because a bare version byte was consumed by the
  released unframed reader as its items-vector length and the payload
  reinterpreted (full-consumption repro); the framed reader requires
  the magic exactly. The human-readable form nests version 2 as well as
  3 under `body`: the actual released v5.0.1 derive (no read_mode)
  ignores unknown flat keys, so a flat read-mode payload silently
  decoded as plain key selection. Flat maps smuggling read_mode/limit
  are refused; cross-generation tests now target the released layout in
  both directions and both format families.
- Merge lift is order-independent: sub-level branches merge in a
  canonical partition — limit-free branches first through the full
  machinery (still limit-free at that point), limited grafts last, with
  collisions judged only against the structures they actually touch;
  permutation regression added.
- The specialized adapters (MMR / Bulk / Dense / CT) order rows by the
  query direction before the cap applies, so a descending cap keeps the
  last positions; exact-key descending tests for all four (including a
  CommitmentTree fixture).
- The BulkAppend prover charges semantically matched rows (the same
  expand_query_to_u64_positions set the verifier uses) instead of the
  bounding span, which desynchronized the shared budget on sparse
  queries and made the verifier reject honest proofs; regression with
  positions {0,100} under a global limit plus a later sibling layer.
- The empty-child verifier arm reports the matched parent when
  add_parent_tree_on_subquery requests it, so empty and non-empty
  parents behave alike (parent rows stay uncharged per the documented
  flag limitation); mixed empty/non-empty regression added.
- The 8-site lower-layer cap derivation is one shared helper
  (V1LimitState::effective_lower_layer_limit) so prover and verifier
  cannot silently diverge (CodeRabbit).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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