Skip to content

Replace indexed-tree placeholder rows with canonical references to primary entries before release #814

Description

@QuantumExplorer

Revised 2026-08-19 to fold in the review consensus from the comment thread (Claude Fable 5 + GPT-5.6 Sol): corrected count-axis representation, locked immediate-primary-node binding, shape-complete proof witness, dedicated logical-origin machinery, and added tests. See the "Locked decisions" section.

Summary

Replace the current indexed-tree secondary-row representation before indexed trees ship.

Today each per-axis secondary (all three are ProvableCountProvableSumTree Merks) stores the ordering value and primary key in the secondary key, but stores only a synthetic aggregate payload with no pointer back to the primary value:

count: (count || primary_key) -> SumItem(count_value_as_sum(count))
sum:   (sortable_sum || primary_key) -> SumItem(sum)
avg:   (sortable_avg || primary_key) -> ItemWithSumItem([], sum)

The new representation must store a canonical GroveDB reference back to the primary entry. All three axes use one canonical element family:

count: (count || primary_key)
          -> ReferenceWithSumItem(SiblingReference(primary_key),
                                  max_reference_hop = Some(1),
                                  sum = count_value_as_sum(count))
sum:   (sortable_sum || primary_key)
          -> ReferenceWithSumItem(SiblingReference(primary_key),
                                  max_reference_hop = Some(1),
                                  sum = primary_sum)
avg:   (sortable_avg || primary_key)
          -> ReferenceWithSumItem(SiblingReference(primary_key),
                                  max_reference_hop = Some(1),
                                  sum = primary_sum)

The reference carries the original value relationship; the carried sum supplies each axis's sum-half contribution — the count axis carries the count-as-sum so the #806 band Total remains a single committed scalar. The original key remains in the secondary key because it is required for uniqueness, deterministic tie-breaking, range boundaries, and reversible key decoding.

This is a replacement, not a second index mode. Indexed trees have not shipped, so we should remove the placeholder representation rather than preserve compatibility or add migration machinery.

Why change this

The current index is only an ordering accelerator. Axis reads return (ordering_value, primary_key), discard the secondary payload, and require a later primary lookup for every selected value.

Reference-backed rows make the secondary a proper authenticated index:

  • each secondary row points to the canonical primary entry;
  • ranked and ranged reads can return the original value naturally;
  • a proof can authenticate the ranked row and the referenced value together;
  • ReferenceWithSumItem already exists specifically for ranked/sortable index entries whose rows also contribute a sum;
  • callers no longer need k independent primary reads and, for verified reads, k separate primary inclusion proofs after a top-k result;
  • the model is easier to explain: the secondary contains sorted references, not placeholder values with an implicit pointer hidden in the key suffix.

The tradeoff is intentional: when a primary value hash changes, its secondary references must be refreshed even if count, sum, and average do not move. Indexed trees are unshipped, and read semantics should be made correct and ergonomic before optimizing around the placeholder layout.

Required semantics

1. Canonical row values

There must be exactly one canonical row-value encoding per axis:

Axis Secondary tree type Canonical row value Own aggregate
Count ProvableCountProvableSumTree ReferenceWithSumItem(SiblingReference(primary_key), hop=1, count_value_as_sum(count)) count = 1, sum = count-as-sum
Sum ProvableCountProvableSumTree ReferenceWithSumItem(SiblingReference(primary_key), hop=1, sum) count = 1, sum = primary sum
Avg ProvableCountProvableSumTree ReferenceWithSumItem(SiblingReference(primary_key), hop=1, sum) count = 1, sum = primary sum

Every axis stays on the dual-aggregate ProvableCountProvableSumTree. This is a security requirement, not a convenience: the single-aggregate count and sum node hashes share an untagged preimage layout, so a single-aggregate secondary would let a count proof be node-type-relabeled into a byte-different "sum" proof reconstructing the identical root (#809 audit finding C — see the note at operations/indexed_tree.rs:65-76). The count axis's carried sum preserves the #806 band Total; a plain Reference would contribute (1, 0) and silently zero it.

Do not retain SumItem(count_value_as_sum(count)), SumItem(sum), or ItemWithSumItem([], sum) as accepted indexed-secondary rows.

Use SiblingReference rather than AbsolutePathReference so row size does not grow with grove depth. The secondary is physically stored under a derived storage prefix (blake3(primary_prefix ‖ axis_tag)) and has no SubtreePath at all — the existing follow_reference / MerkCache machinery is entirely path-keyed and cannot express a secondary row's origin. The logical-origin rule (origin = the indexed primary's logical path, storage = the derived secondary prefix) is therefore a new dedicated component, not an adaptation: an indexed-secondary reference context shared by mirror writes, direct reads, proof post-processing, proof verification, verify_grovedb/reconciliation, and cost estimation. No component may manufacture a fake SubtreePath for the secondary, allow ordinary MerkCache lookup to conflate the secondary with the primary, or interpret the derived secondary prefix as a user-visible GroveDB path.

2. One row per primary entry per configured axis

For every primary entry and each configured axis:

  • derive exactly one secondary key from the primary entry's current count/sum state and primary key;
  • store exactly one canonical reference row at that key;
  • reject or report missing, duplicate, malformed, non-canonical, stale, or orphaned rows;
  • preserve deterministic tie-breaking through the primary-key suffix.

3. Reference commitment refresh

A reference row's committed value hash depends on the referenced target: the row binds the immediate primary node's Merk-stored committed value hash (see §4). Therefore mirroring can no longer compare only (secondary_key, aggregate_payload) — the captured transition state must include the primary node's post-apply committed value hash (or equivalently, the mirror must force a same-key PutCombinedReference for every touched surviving primary entry). Note that GroveOp::RefreshReference touching the primary key is necessary but not sufficient under the current mirror: today's old_entry == new_entry skip would swallow a refresh whose aggregates are unchanged.

For every touched primary key:

  • deletion: delete the old secondary key on every configured axis;
  • insertion: insert a canonical reference row using the post-apply referenced target hash;
  • update that moves on an axis: delete the old key and insert the new reference row;
  • update that remains at the same secondary key but changes the referenced value hash: replace/put the reference row so its combined reference hash is refreshed;
  • update whose primary value hash and axis state are both unchanged: it may be skipped;
  • an average update that preserves the average key but changes sum must refresh both the ReferenceWithSumItem payload and its referenced target commitment.

All per-axis changes must remain atomic with the primary mutation and with the indexed element's primary/secondary root propagation.

4. Preserve currently accepted child shapes

Do not silently redefine indexed trees as leaf-only.

The replacement must account for every element shape currently accepted by PCIT, PSIT, and PCPSIT, including:

  • item-like elements;
  • Reference and ReferenceWithSumItem primary entries;
  • wrappers allowed by the indexed primary;
  • empty and populated subtree elements;
  • nested indexed trees and other aggregate-bearing subtrees.

If the generic reference batch or proof machinery cannot represent an updated subtree target, extend it or add a narrowly scoped indexed-secondary reference path. Retaining placeholder rows for tree children is not an acceptable fallback.

Binding rule (LOCKED — see Locked decisions #4/#5): the secondary row binds exactly one hop to the immediate primary node's Merk-stored committed value hash, regardless of the primary element's shape:

secondary_row_value_hash =
    combine_hash(
        H(canonical_secondary_reference_bytes),
        primary_node_committed_value_hash
    )

Terminal binding is the wrong invariant for the internal row: a mutation to an external terminal target would staleness the row with no local trigger, making §3's atomicity unachievable and stale-detection unable to distinguish by-design drift from corruption. With immediate binding the invariant is local and mirror-maintainable; if the primary entry is a Reference, its RefreshReference operation touches the indexed primary key and (with §3's expanded state tracking) the mirror refreshes the row. Reads authenticate the immediate primary entry, then apply ordinary GroveDB get/follow-reference semantics and return the terminal value — externally consistent with reading the primary by key. (Read-side terminal resolution is not blocked by the row's hop=1: follow_reference budgets from the global MAX_REFERENCE_HOPS, not the element's declared hop field.)

This is dedicated internal indexed-tree behavior, not a relaxation of ordinary user-reference semantics. Ordinary user references retain their present terminal-reference contract and diagnostics — an ordinary max_hop = 1 reference to another Reference stays ill-formed. The recursions_allowed == 1 fast path at batch/mod.rs:1692-1710 already implements the immediate-node hash read; its well-formed-user contract comment must be amended to say it applies to ordinary references while indexed-secondary rows intentionally reuse the immediate-node behavior through their dedicated mirror path. If common code is extracted, the binding choice must be an explicit typed context/mode, never inferred merely from max_reference_hop == 1.

5. Read results resolve the reference

The standard non-aggregate axis APIs should return the selected primary value together with the ordering value and primary key.

Use one shared result model across count, sum, and average, conceptually:

IndexedAxisEntry<T> {
    ordering_value: T,
    primary_key: Vec<u8>,
    value: Element,
}

The exact Rust shape may differ. (The current axis tuple API is already internally consistent — (ordering_value, primary_key) across all three axes; the purpose of IndexedAxisEntry is to add a named resolved-value model, and while unifying, fix the aggregate per-key carriers' opposite (key, value) order.)

Update:

  • direct top-k reads;
  • paginated top-k reads;
  • range reads;
  • axis-shaped PathQuery reads (PathQueryRun::AxisEntries);
  • branched axis reads (PathQueryRun::BranchedAxisEntries);
  • run_path_query dispatch;
  • verified result types.

Aggregate-only APIs continue to return their aggregate without enumerating or resolving rows.

A deliberately named keys-only helper may remain if an internal caller needs it, but the public/default indexed read must no longer expose placeholder-row semantics or require callers to manually perform k primary reads.

6. Proofs authenticate resolved values

Axis proof generation must apply the GroveDB reference post-pass to returned secondary rows and emit reference-aware proof nodes carrying the resolved target value.

The implementation must cover:

  • ordinary range/top-k proofs;
  • count-offset paginated proofs;
  • branched indexed-axis proofs;
  • axis-shaped PathQuery / GroveDBProof V1 envelopes;
  • count, sum, and count+sum feature-bearing reference proof nodes.

The count-offset path currently does not dereference returned references (prover-side rejection at merk/src/proofs/query/count_offset/emit.rs:288, verifier-side at operations/proof/verify.rs:686, short-circuit before the V1 reference post-pass in generate.rs). That gap must be removed rather than bypassed.

The existing KVRefValueHash* node family cannot represent a tree-shaped immediate target: verification recomputes the target hash as value_hash(referenced_value_bytes), which can never reproduce a tree target's combined hash. A new proof node family or indexed-reference target witness is therefore mandatory, not conditional. The witness must let the verifier reconstruct the immediate primary node's exact committed value hash for every accepted primary shape:

  • simple item: H(element bytes);
  • ordinary layered tree: combine(H(element bytes), child root);
  • indexed tree: combine-three with primary root plus secondary root/axes digest;
  • non-Merk tree: combine with its authenticated state root;
  • primary Reference/ReferenceWithSumItem: combine H(primary-reference bytes) with the next target commitment, continuing as a bounded chain (returning the terminal value requires carrying the intermediate primary-reference bytes/commitment as well as the terminal value witness);
  • wrappers: preserve the serialized outer bytes while applying the underlying commitment rule.

The verifier must authenticate all of the following:

  1. the secondary key and its ordering prefix;
  2. the primary key decoded from the secondary-key suffix;
  3. the secondary row's reference commitment;
  4. the resolved target value;
  5. the per-node count and/or sum aggregate;
  6. the secondary root;
  7. the indexed element's primary root plus secondary root/axes digest;
  8. the ancestor chain to the GroveDB root.

The existing generic reference proof node only carries a hash of the serialized reference and therefore cannot verify that the stored path is canonical. The indexed proof shape must carry the canonical secondary reference bytes, not merely their hash, so verification can enforce:

reference type == SiblingReference
reference key  == primary key decoded from the secondary-key suffix
max hops       == 1
carried sum    == axis_payload_sum(axis, count, sum)

Whether this lands as a new KVIndexedRefValueHashCountSum node family or as an envelope-level target witness is an implementation choice; self-contained recomputation of the complete hash chain is the requirement. Axis proof verification must not merely assume that a committed reference points to the key encoded in the secondary row.

Tampering with the reference path, target value, target hash, primary-key suffix, aggregate payload, axis tag, or traversal parameters must fail verification.

7. Integrity verification

Update verify_grovedb's indexed-primary/secondary consistency walk (verify_indexed_axis_content). Indexed rows are validated in that dedicated context, never routed through the generic Reference arm — the generic verifier retains its existing terminal-reference semantics for ordinary user references, and the natural boundary already exists (verify_grovedb opens the secondary by derived prefix and raw-iterates; it never recurses into it as an ordinary subtree). The checker must not call terminal follow_reference to validate an internal row.

For every expected row it must verify:

  • the exact derived secondary key;
  • the exact canonical ReferenceWithSumItem form (bytes: SiblingReference, hop = Some(1), axis payload sum);
  • the reference path targets the expected primary key, agreeing with the secondary-key suffix;
  • the row's combined hash recomputes from the canonical reference bytes and the immediate primary node's Merk-stored value hash (read directly, no terminal follow);
  • the ReferenceWithSumItem sum equals the axis payload sum — count_value_as_sum(count) on the count axis, the primary sum on the sum/avg axes;
  • no legacy placeholder payload is accepted;
  • no extra or duplicate row exists.

Keep distinct diagnostic sentinels for malformed key, wrong reference path, stale target hash, wrong sum payload, missing row, duplicate row, and orphan row so corruption reports remain actionable.

8. Costs and estimates

Reference rows are larger and value-only primary updates now write every configured secondary. Update actual and estimated costs accordingly:

  • serialized row size per axis;
  • reference-path bytes and combined-reference hash work;
  • post-primary target resolution/value-hash reads;
  • same-key reference refresh;
  • one through three secondary writes for PCPSIT;
  • deep subtree propagation;
  • proof generation and verification;
  • average-case and worst-case batch estimates;
  • benchmarks and documentation examples.

Exact-cost tests must cover a value-only update whose count and sum do not change. It should demonstrate the new intentional write amplification.

Locked decisions

Consensus from the review thread. Implementation should proceed only with these points locked:

  1. All axes remain dual-aggregate ProvableCountProvableSumTree secondaries (feat: dual-aggregate count secondary — count+Total served (closes #806) #809 finding C forbids single-aggregate downgrade without domain separation).
  2. All rows use canonical one-hop ReferenceWithSumItem values — one element family across count, sum, and avg.
  3. The count axis carries count_value_as_sum(count) as its sum; sum/avg carry the primary sum (preserves the Count-axis secondary cannot answer 'total of counts in a band' — decide before V4 activation #806 band Total).
  4. Rows bind exactly one hop to the immediate primary node's Merk-stored committed value hash, regardless of the primary element's shape.
  5. Read semantics may subsequently resolve the primary entry to its terminal value via ordinary GroveDB semantics.
  6. Proof support includes canonical reference bytes (type/key/hops/sum enforceable) plus a shape-complete immediate-target commitment witness.
  7. Secondary logical-origin resolution is purpose-built shared machinery — the secondary has no SubtreePath; no fake paths, no MerkCache conflation.
  8. No placeholder compatibility mode or migration path is retained.
  9. The one-hop immediate-node binding is dedicated internal indexed-tree behavior, not a relaxation of ordinary user-reference semantics: ordinary max_hop = 1 reference-to-reference stays ill-formed with its current diagnostics; the batch/mod.rs:1692 well-formed contract comment is amended accordingly; verify_indexed_axis_content and the mirror share the same immediate-node rule; any extracted common code takes the binding choice as an explicit typed mode, never inferred from max_reference_hop == 1.

Implementation plan

Phase 1: Introduce the canonical row/reference helpers

  1. Replace axis_row_payload with a helper that builds the canonical axis reference for (axis, primary_key, count, sum) — carried sum per locked decision 3.
  2. Add a single helper for the logical reference origin of an indexed secondary.
  3. Add validation/decoding helpers that require SiblingReference(primary_key) with hop = Some(1).
  4. Confirm ReferenceWithSumItem produces the correct (1, sum) feature type in ProvableCountProvableSumTree.
  5. Remove placeholder-specific helpers and assumptions.

Primary locations:

  • grovedb/src/batch/indexed_tree/mirror.rs
  • grovedb/src/operations/indexed_tree.rs
  • grovedb-element/src/element/*
  • merk/src/element/*

Phase 2: Carry target commitment state through the mirror

  1. Expand the captured transition state beyond AggregatePair to include the primary node's committed value hash.
  2. Capture the old axis key information needed for deletion.
  3. After the primary Merk apply, read the immediate primary node's Merk-stored value hash (locked decision 4 — no terminal follow).
  4. Build PutCombinedReference/replace operations for canonical secondary rows.
  5. Refresh same-key rows whenever the referenced target hash or ReferenceWithSumItem payload changes.
  6. Apply all moves for one axis as one sorted Merk batch, preserving unique batch keys.
  7. Return each secondary's post-mirror root hash/root key for the existing bubble-up path.
  8. Cover dedicated APIs, apply_batch, apply_partial_batch, same-batch reference targets, and nested indexed ancestors.

Primary locations:

  • grovedb/src/batch/indexed_tree/pre_state.rs
  • grovedb/src/batch/indexed_tree/mirror.rs
  • grovedb/src/batch/mod.rs
  • grovedb/src/lib.rs
  • grovedb/src/operations/indexed_tree.rs

Phase 3: Resolve references in direct reads

  1. Introduce the shared IndexedAxisEntry result type.
  2. Change direct axis iterators to decode the ordering/key and resolve the canonical row reference.
  3. Thread transaction, cache, and cost accumulation through resolution.
  4. Update the run_path_query / PathQueryRun::AxisEntries adapters.
  5. Keep aggregate-only reads on the secondary aggregate primitives without row resolution.
  6. Remove documentation and examples saying callers must call db.get for every returned key.

Phase 4: Make every axis proof reference-aware

  1. Reuse/extract the regular GroveDB reference proof post-pass for hidden indexed secondaries.
  2. Teach ordinary axis proofs to replace raw reference nodes with the resolved-value reference node family.
  3. Add the equivalent post-pass to count-offset proof generation.
  4. Extend count-offset verification to accept and authenticate resolved reference nodes.
  5. Update axis envelope/result decoding to return IndexedAxisEntry values.
  6. Authenticate canonical sibling-reference metadata against the decoded primary-key suffix.
  7. Update branched proofs and GroveDBProof V1 axis envelopes.
  8. Add graceful rejection for unsupported or malformed reference targets; no panic paths.

Primary locations:

  • grovedb/src/operations/proof/indexed_axis/*
  • grovedb/src/operations/proof/generate.rs
  • grovedb/src/operations/proof/verify.rs
  • merk/src/proofs/query/*
  • merk/src/proofs/query/count_offset/*

Phase 5: Strengthen integrity checks and cleanup behavior

  1. Rewrite the expected/actual secondary-row comparison around canonical references.
  2. Follow or otherwise validate target commitments during verify_grovedb.
  3. Reject all placeholder rows.
  4. Confirm DeleteTree and indexed overwrite cleanup still remove all per-axis namespaces.
  5. Confirm state-sync's explicit indexed-tree rejection remains correct until indexed state sync is implemented.
  6. Add corruption tests for each new diagnostic category.

Phase 6: Update costs, tests, benchmarks, and documentation

  1. Update specialized value-cost sizing and average/worst-case estimators.
  2. Update exact-cost fixtures for direct and batch operations.
  3. Update indexed benchmarks to include value-only reference refresh and resolved reads.
  4. Rewrite docs/book/src/count-indexed-tree.md around sorted references.
  5. Update API docs, examples, diagrams, and terminology from "mirror payload" to "reference row".
  6. Remove stale resolve_values design text: resolution is now the normal read behavior.
  7. Remove all tests that assert legacy placeholder bytes and replace them with canonical-reference assertions.
  8. Fix the two count-offset doc comments that currently claim a reference post-pass already exists (merk/src/proofs/query/count_offset/verify.rs:76-79, count_offset/mod.rs:22) — Phase 4 makes them true.
  9. Amend the batch/mod.rs:1692 well-formed-user contract comment per locked decision 9.

Phase 7: Remove the legacy representation completely

Before merge:

  • no indexed-secondary writer emits SumItem(count_value_as_sum(count)), SumItem(sum), or ItemWithSumItem([], sum);
  • no reader or verifier accepts those values as valid indexed rows;
  • no compatibility flag, migration path, or dual-mode element field remains;
  • golden proof/state fixtures are regenerated for the reference representation;
  • the Grove version is updated only as needed for internal version tables/tests, not to preserve an unshipped state format.

Test matrix

At minimum, cover the Cartesian product where meaningful:

Variants and axes

  • PCIT: count
  • PSIT: sum
  • PCPSIT: count only, sum only, avg only, and all multi-axis combinations

Mutations

  • insert;
  • delete;
  • replace;
  • patch;
  • value-only update with unchanged count/sum;
  • count-only move;
  • sum-only move;
  • count+sum move;
  • average changes;
  • average unchanged while count/sum scale proportionally;
  • multiple same-level mutations in one batch;
  • apply_partial_batch;
  • deep mutation below a tree-shaped primary entry with aggregates unchanged: the synthesized propagation op rewrites the primary node and every configured secondary performs a same-key reference refresh (this exact path — today it is skipped as aggregate-unchanged);
  • RefreshReference on a Reference-shaped primary with aggregates unchanged → same-key secondary refresh;
  • nested indexed ancestors;
  • transaction commit and rollback.

Primary entry shapes

  • Item;
  • SumItem;
  • ItemWithSumItem;
  • Reference;
  • ReferenceWithSumItem;
  • allowed wrappers;
  • empty aggregate tree;
  • populated aggregate tree;
  • nested indexed tree.

Reads and proofs

  • ascending/descending;
  • exact/range/full-range;
  • top-k;
  • offset pagination, including exhausted offsets;
  • aggregate count/sum queries;
  • branched reads;
  • run_path_query axis dispatch (AxisEntries / BranchedAxisEntries);
  • an indexed secondary pointing to a Reference-shaped primary is verify_grovedb-clean and returns the terminal value after authentication;
  • an ordinary user max_hop = 1 reference to another Reference retains its current invalid/diagnosed behavior;
  • direct/proved result equivalence;
  • proof size/cost regression measurements.

Tampering/corruption

  • wrong SiblingReference key;
  • non-sibling reference type;
  • wrong max_reference_hop;
  • stale referenced target hash (immediate-primary binding), including when primary aggregates are unchanged;
  • wrong resolved value;
  • wrong ReferenceWithSumItem sum;
  • secondary-key ordering prefix mismatch;
  • secondary-key primary suffix mismatch;
  • missing row;
  • duplicate row;
  • orphan row;
  • legacy placeholder row;
  • malformed reference bytes;
  • reference cycle/hop-limit failure;
  • target deletion;
  • target tree child-hash tampering.

Acceptance criteria

  • Every indexed secondary row is a canonical one-hop ReferenceWithSumItem to its primary entry, on a dual-aggregate PCPS secondary, binding the immediate primary node's committed value hash.
  • Ordinary user-reference semantics and diagnostics are unchanged; the indexed one-hop binding is an explicit typed mode.
  • The placeholder row representation is completely removed and rejected by integrity verification.
  • Value-only primary updates refresh every affected secondary reference atomically.
  • All currently valid indexed child element shapes remain supported.
  • Direct top-k/range/paginated reads return ordering value, primary key, and resolved Element.
  • All indexed-axis proof families authenticate and return the resolved Element.
  • Count-offset proofs support reference rows and no longer reject them for lacking a post-pass.
  • The verifier authenticates the canonical reference target against the primary-key suffix.
  • verify_grovedb detects stale, malformed, missing, duplicate, and orphan reference rows with actionable diagnostics.
  • Direct and batch APIs produce identical roots for equivalent operations.
  • Actual, average-case, and worst-case costs include reference refresh work.
  • Documentation and benchmarks describe only the reference-backed system.
  • Full formatting, lint, unit, integration, proof-tamper, exact-cost, and all-feature test suites pass.

Non-goals

  • Supporting both placeholder and reference-backed indexed trees.
  • Migrating a released database containing the placeholder layout.
  • Making indexed state sync work; its existing explicit rejection remains until separately implemented.
  • Removing the ordering value or primary key from secondary keys.
  • Sparse/conditional indexing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions